@onlineapps/conn-infra-secrets 1.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js CHANGED
@@ -9,86 +9,358 @@
9
9
  * store (oagen_meta.secret) is owned by biz-meta and never touched on this path.
10
10
  *
11
11
  * Redis projection (see secretbox.md §5) — flat key (meta StateConnector has no
12
- * hash ops), value = base64( iv || authTag || ciphertext ):
13
- * state:meta:secret:<tenant_id>:<workspace_id|->:<ref>
12
+ * hash ops), value = the sealed blob described in src/crypto.js:
13
+ * state:meta:secret:<tenant_id>:<workspace_id>:<ref>
14
+ *
15
+ * The master keys arrive as the declared ORDERED LIST (`SECRETS_MASTER_KEYS`,
16
+ * owner decision 2026-09-14, confirmation secretbox-master-keys 001): the first
17
+ * key seals every new write, every key in the list opens what it sealed, selected
18
+ * by the blob's own `key_id`. That is what makes a rotation a switch rather than a
19
+ * jump — while both keys are listed, rows on either of them are readable.
14
20
  *
15
21
  * Contract: get(name, scope) => Promise<string>. The ContextBuilder facade passes
16
22
  * the invocation's { tenant_id, workspace_id } as scope. No fallbacks: a missing
17
- * projection throws SECRET_NOT_FOUND; a bad blob throws SECRET_DECRYPT_FAILED.
23
+ * projection throws SECRET_NOT_FOUND; a blob sealed with a key that is not in the
24
+ * list throws SECRET_KEY_UNKNOWN (never a "try every key" pass); a blob that does
25
+ * not open under its own key — tampered, or lifted out of the tenant/workspace/ref
26
+ * it was bound to — throws SECRET_DECRYPT_FAILED; a scope that cannot address one
27
+ * concrete workspace throws SECRET_SCOPE_MISSING before any Redis round trip.
18
28
  *
19
29
  * @see api/docs/architecture/secretbox.md
30
+ * @see api/docs/governance/confirmations/secretbox-tenant-wide-scope.md
20
31
  */
21
32
 
22
33
  const Redis = require('ioredis');
23
- const { loadMasterKey, open } = require('./crypto');
34
+ const {
35
+ loadMasterKeys, openWithDeclaredVersion, SecretCryptoError, MASTER_KEYS_ENV
36
+ } = require('./crypto');
37
+ const { normalizeWorkspaceId } = require('./workspaceId');
24
38
 
25
39
  // Meta owns the projection; its StateConnector prefixes keys with `state:meta:`.
26
40
  const DEFAULT_KEY_PREFIX = 'state:meta:secret:';
27
41
 
28
- // biz-meta stores tenant-wide secrets with workspace_id 0 (`TENANT_WIDE = 0` in
29
- // meta/src/handlers/secrets.js) and projects them under the `-` segment. The
30
- // reader must apply the identical mapping — a caller that legitimately passes 0
31
- // would otherwise read `:0:`, a key nothing ever writes. Omitted/null means the
32
- // same thing on the writing side, so all four spellings collapse to `-`.
33
- const TENANT_WIDE = 0;
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'];
34
67
 
35
68
  /**
36
- * @param {number|string|null|undefined} workspaceId
37
- * @returns {string} the key segment: `-` for tenant-wide, the id otherwise
69
+ * Did the server answer and refuse?
70
+ * @param {Error|null|undefined} err
71
+ * @returns {boolean}
38
72
  */
39
- function workspaceSegment(workspaceId) {
40
- if (workspaceId === null || workspaceId === undefined) return '-';
41
- if (workspaceId === TENANT_WIDE || workspaceId === String(TENANT_WIDE)) return '-';
42
- return String(workspaceId);
73
+ function isServerRefusal(err) {
74
+ if (!err || typeof err.message !== 'string') return false;
75
+ return SERVER_REFUSAL_PREFIXES.some((prefix) => err.message.startsWith(prefix));
43
76
  }
44
77
 
45
78
  class SecretResolutionError extends Error {
46
- constructor(code, message) {
47
- super(message);
79
+ constructor(code, message, options) {
80
+ super(message, options);
48
81
  this.name = 'SecretResolutionError';
49
82
  this.code = code;
50
83
  }
51
84
  }
52
85
 
86
+ // The rule "which workspace_id can address a secret" moved into its own module,
87
+ // src/workspaceId.js: src/crypto.js needs the same normalization for the bound
88
+ // context (AAD), and a second copy of the rule is exactly what
89
+ // .claude/rules/change-discipline.md § One rail per concern forbids. It stays
90
+ // part of this package's public API — re-exported at the bottom of this file —
91
+ // so the writing side keeps importing it from here.
92
+
93
+ /**
94
+ * @param {*} workspaceId
95
+ * @returns {string} the key segment for one concrete workspace
96
+ * @throws {SecretResolutionError} SECRET_SCOPE_MISSING for any unusable value
97
+ */
98
+ function workspaceSegment(workspaceId) {
99
+ const ws = normalizeWorkspaceId(workspaceId);
100
+ if (ws === null) {
101
+ throw new SecretResolutionError(
102
+ 'SECRET_SCOPE_MISSING',
103
+ `[conn-infra-secrets] Invalid workspace_id "${workspaceId}" - Expected a positive integer naming `
104
+ + 'the workspace the secret belongs to. Fix: pass the concrete workspace_id; the tenant-wide '
105
+ + 'scope was retired by owner decision 2026-09-03 '
106
+ + '(api/docs/governance/confirmations/secretbox-tenant-wide-scope.md 001).'
107
+ );
108
+ }
109
+ return String(ws);
110
+ }
111
+
53
112
  function projectionKey(tenantId, workspaceId, ref, keyPrefix = DEFAULT_KEY_PREFIX) {
54
113
  if (tenantId === undefined || tenantId === null) {
55
- throw new SecretResolutionError('SECRET_SCOPE_MISSING', '[conn-infra-secrets] tenant_id is required to resolve a secret.');
114
+ throw new SecretResolutionError(
115
+ 'SECRET_SCOPE_MISSING',
116
+ '[conn-infra-secrets] tenant_id is required to resolve a secret - Expected the tenant the secret '
117
+ + 'belongs to. Fix: pass scope.tenant_id from the operation context.'
118
+ );
56
119
  }
57
120
  return `${keyPrefix}${tenantId}:${workspaceSegment(workspaceId)}:${ref}`;
58
121
  }
59
122
 
123
+ // `REDIS_URL` is the platform's single rail for the Redis endpoint AND its
124
+ // credential (`service-common` 3.0.2 hands the whole URL to node-redis;
125
+ // `mq-client-core` 3.2.0 reads the credential off the URL). Anything else this
126
+ // package might accept beside it would be a second rail for the same fact.
127
+ const REDIS_URL_PROTOCOLS = new Set(['redis:', 'rediss:']);
128
+
129
+ /**
130
+ * Options beside `redisUrl` that used to carry a fact the URL already carries.
131
+ * They are REFUSED by name rather than ignored: an ignored option looks accepted
132
+ * and leaves the caller believing a password reached the server
133
+ * (`.claude/rules/change-discipline.md` § One rail per concern; principle 11,
134
+ * clean break — no shim that keeps reading them).
135
+ */
136
+ const RETIRED_OPTIONS = {
137
+ password: 'the Redis password comes from the userinfo of redisUrl (redis://user:password@host:port)',
138
+ db: 'the Redis database index comes from the path of redisUrl (redis://host:port/3)'
139
+ };
140
+
141
+ function refuseRetiredOptions(config) {
142
+ for (const [name, replacement] of Object.entries(RETIRED_OPTIONS)) {
143
+ if (config[name] === undefined) continue;
144
+ throw new Error(
145
+ `[conn-infra-secrets] Option "${name}" is not accepted - ${replacement}. `
146
+ + `Fix: drop "${name}" from the SecretsConnector config and put the value in REDIS_URL `
147
+ + '(api/config/env-active/shared.env); a value beside the URL is a second rail for a fact '
148
+ + 'the URL already carries. The refused value is not echoed here.'
149
+ );
150
+ }
151
+ }
152
+
153
+ /**
154
+ * The database index the URL declares, or `null` when it declares none.
155
+ *
156
+ * No path (`redis://h:6379`) and an empty path (`redis://h:6379/`) declare
157
+ * nothing, and the connector then passes no `db` at all — ioredis selects its own
158
+ * default, database 0. That default belongs to ioredis and is named here rather
159
+ * than re-implemented as `config.db || 0`, which would also swallow a malformed
160
+ * declaration into a 0 nobody asked for (principle 3, No Fallbacks).
161
+ */
162
+ function databaseFromPath(pathname) {
163
+ const declared = String(pathname || '').replace(/^\//, '');
164
+ if (declared === '') return null;
165
+ if (!/^\d+$/.test(declared)) {
166
+ throw new Error(
167
+ `[conn-infra-secrets] redisUrl declares database "${declared}" - Expected a non-negative `
168
+ + 'integer as the URL path, e.g. redis://host:6379/3. Fix: correct the path of REDIS_URL in '
169
+ + 'shared.env, or drop it to use the default database.'
170
+ );
171
+ }
172
+ return Number.parseInt(declared, 10);
173
+ }
174
+
175
+ /**
176
+ * Build the ioredis options from the URL — endpoint, ACL user and password alike.
177
+ *
178
+ * Measured reason this function exists (INFRA, dev, 2026-09-15 21:20Z): the
179
+ * constructor used to take `hostname`/`port` only and expect the password in a
180
+ * separate `config.password`, so a URL carrying userinfo lost it and ioredis
181
+ * connected without AUTH — gateway crash loop (RestartCount 51), dispatcher and
182
+ * ingest failing on the first secret read.
183
+ *
184
+ * Nothing here echoes the URL or any part of its userinfo: these messages reach
185
+ * logs, and the value carries the password.
186
+ */
187
+ function redisOptionsFromUrl(redisUrl) {
188
+ let parsed;
189
+ try {
190
+ parsed = new URL(redisUrl);
191
+ } catch (err) {
192
+ throw new Error(
193
+ '[conn-infra-secrets] redisUrl is not a URL - Expected redis://[user:password@]host:port[/db] '
194
+ + '(or rediss:// for TLS), the one rail that carries both the endpoint and the credential. '
195
+ + 'Fix: set REDIS_URL in api/config/env-active/shared.env to the full URL; a bare host:port '
196
+ + 'is not accepted. The value is not echoed here because it carries the password.',
197
+ { cause: err }
198
+ );
199
+ }
200
+ if (!REDIS_URL_PROTOCOLS.has(parsed.protocol)) {
201
+ throw new Error(
202
+ `[conn-infra-secrets] redisUrl has scheme "${parsed.protocol}" - Expected redis: or rediss:. `
203
+ + 'Fix: set REDIS_URL in api/config/env-active/shared.env to redis://host:port (rediss:// for TLS).'
204
+ );
205
+ }
206
+ if (!parsed.hostname) {
207
+ throw new Error(
208
+ '[conn-infra-secrets] redisUrl declares no host - Expected redis://host:port. '
209
+ + 'Fix: set REDIS_URL in api/config/env-active/shared.env to the full endpoint.'
210
+ );
211
+ }
212
+ if (!parsed.port) {
213
+ throw new Error(
214
+ '[conn-infra-secrets] redisUrl declares no port - Expected redis://host:port; the port is '
215
+ + 'topology and this package declares no default for it. Fix: set REDIS_URL in '
216
+ + 'api/config/env-active/shared.env to the full endpoint, e.g. redis://api_node_cache:6379.'
217
+ );
218
+ }
219
+
220
+ const options = {
221
+ host: parsed.hostname,
222
+ port: Number.parseInt(parsed.port, 10),
223
+ lazyConnect: true
224
+ };
225
+ // Redis 6 ACL: the URL may name a user as well as a password. An absent one is
226
+ // ABSENT — never `''`, which ioredis would send as `AUTH ""`.
227
+ if (parsed.username) options.username = decodeURIComponent(parsed.username);
228
+ if (parsed.password) options.password = decodeURIComponent(parsed.password);
229
+ const db = databaseFromPath(parsed.pathname);
230
+ if (db !== null) options.db = db;
231
+ return options;
232
+ }
233
+
60
234
  class SecretsConnector {
61
235
  /**
62
236
  * @param {Object} config
63
- * @param {string} config.redisUrl - redis://host:port (required)
64
- * @param {string} config.masterKeyBase64 - base64 32-byte AES-256-GCM key (required)
65
- * @param {string} [config.password] - Redis password
66
- * @param {number} [config.db=0] - Redis database index
237
+ * @param {string} config.redisUrl - `redis://[user:password@]host:port[/db]` or `rediss://…`
238
+ * (required). The URL is the ONLY rail: endpoint, ACL user, password and
239
+ * database index all come off it. `config.password` and `config.db` are
240
+ * refused by name.
241
+ * @param {string} config.masterKeys - the declared list, `<key_id>:<base64 32-byte key>[,…]`
242
+ * (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)
67
244
  */
68
245
  constructor(config = {}) {
69
246
  if (!config.redisUrl) {
70
247
  throw new Error('[conn-infra-secrets] redisUrl is required - Fix: pass REDIS_URL.');
71
248
  }
72
- this._key = loadMasterKey(config.masterKeyBase64);
249
+ refuseRetiredOptions(config);
250
+ this._keyring = loadMasterKeys(config.masterKeys);
73
251
  this._keyPrefix = config.keyPrefix || DEFAULT_KEY_PREFIX;
74
252
 
75
- let host = config.redisUrl;
76
- let port = 6379;
77
- if (config.redisUrl.startsWith('redis://')) {
78
- const parsed = new URL(config.redisUrl);
79
- host = parsed.hostname;
80
- port = parseInt(parsed.port, 10) || 6379;
81
- }
82
- this._redisOptions = { host, port, password: config.password, db: config.db || 0, lazyConnect: true };
253
+ this._redisOptions = redisOptionsFromUrl(config.redisUrl);
254
+ // What a message of this package may say about the store: host and port,
255
+ // never the URL. The connector keeps the parsed fields and drops the value
256
+ // precisely because the value carries the password, so there is no URL here
257
+ // to redact — and nothing to redact it with (this package declares one
258
+ // dependency, ioredis).
259
+ this._endpoint = `${this._redisOptions.host}:${this._redisOptions.port}`;
83
260
  this.client = null;
261
+ // Whether the CLIENT is ready — socket open, AUTH and SELECT done. It is not
262
+ // a latch set once by connect(): it follows the client through an outage and
263
+ // back (d.573), one rail with `@onlineapps/conn-base-cache` d.572.
84
264
  this.connected = false;
265
+ this._lastError = null;
85
266
  }
86
267
 
268
+ /**
269
+ * Open the connection, or say what the server answered.
270
+ *
271
+ * Until d.566 this method handed the caller ioredis's end-of-socket sentence —
272
+ * `Connection is closed.`, no `cause`, no verdict — while the server's actual
273
+ * answer reached `_lastError` and nothing else. That is how dev incident B88
274
+ * (gateway crash loop, RestartCount 51) could not be read from the error at
275
+ * all: the log said the socket closed, and the `NOAUTH` that closed it was a
276
+ * field nobody printed.
277
+ *
278
+ * Two outcomes now, told apart by `isServerRefusal()` and said in the caller's
279
+ * error: the server ANSWERED and refused (permanent — the attempts end here),
280
+ * or it never answered (transient — the caller may try again). Either way the
281
+ * original error travels as `cause`, so the stack survives the sentence.
282
+ *
283
+ * A failed attempt also takes its client with it. ioredis keeps retrying in the
284
+ * background after `connect()` rejects — measured 2026-09-16: status
285
+ * `reconnecting` on all three failure paths — so a connector that gives up
286
+ * without disconnecting leaves a socket loop nobody owns, and this one never
287
+ * reports `connected` again for it: the lifecycle handlers below act only for
288
+ * the client this connector still holds, so nothing that client emits after it
289
+ * has been discarded can put the connector back in service.
290
+ *
291
+ * What `connected` means afterwards is the client's own READY state, watched
292
+ * rather than assumed (d.573): a Redis that goes away mid-run takes the flag
293
+ * down, so `get()` names the missing store instead of walking into a client
294
+ * with no socket, and the reconnection ioredis performs on its own puts the
295
+ * flag back. Before d.573 the flag was written once and never read off the
296
+ * client again, so an outage decided the fate of every secret read that
297
+ * followed it.
298
+ *
299
+ * @returns {Promise<true>}
300
+ * @throws {Error} naming the server's verdict, with the original error as `cause`
301
+ */
87
302
  async connect() {
88
303
  if (this.connected) return true;
89
- this.client = new Redis(this._redisOptions);
90
- this.client.on('error', (err) => { /* surfaced on get() */ this._lastError = err; });
91
- await this.client.connect();
304
+ this._lastError = null;
305
+ const client = new Redis(this._redisOptions);
306
+ this.client = client;
307
+
308
+ // `ready`, not `connect`: ioredis emits `connect` when the socket opens,
309
+ // which is before AUTH and before SELECT, so it would report a usable store
310
+ // for a connection the server is about to refuse (the same rule as
311
+ // `@onlineapps/conn-base-cache` d.572). Every handler checks that this is
312
+ // still THE client of this connector: a discarded one keeps emitting for a
313
+ // while, and its events belong to nobody.
314
+ const mine = () => this.client === client;
315
+ client.on('ready', () => { if (mine()) this.connected = true; });
316
+ // `close` is the socket going away with a reconnection still to come; `end`
317
+ // is the client giving up for good. Both mean the store is not readable now.
318
+ client.on('close', () => { if (mine()) this.connected = false; });
319
+ client.on('end', () => { if (mine()) this.connected = false; });
320
+
321
+ client.on('error', (err) => {
322
+ // Surfaced on get(), and read below as the verdict of a failed connect().
323
+ this._lastError = err;
324
+ // A refusal the server has already spoken does not change on the next
325
+ // attempt: end the reconnection rather than ask again in a loop.
326
+ if (isServerRefusal(err)) client.disconnect();
327
+ });
328
+
329
+ try {
330
+ await client.connect();
331
+ } catch (rejection) {
332
+ // The verdict comes from whichever error carries it: ioredis delivers the
333
+ // server's sentence on the `error` channel and rejects connect() with the
334
+ // state of the socket, but a rejection that carries the verdict itself is
335
+ // read the same way.
336
+ const refusal = [this._lastError, rejection].find(isServerRefusal) || null;
337
+ const reported = refusal || this._lastError || rejection;
338
+
339
+ client.disconnect();
340
+ this.client = null;
341
+ this.connected = false;
342
+
343
+ if (refusal) {
344
+ throw new Error(
345
+ `[conn-infra-secrets] Failed to connect to Redis at ${this._endpoint} - the server `
346
+ + `answered and refused it with "${refusal.message}". Fix: correct the credential in `
347
+ + 'REDIS_URL (api/config/env-active/shared.env) — the ACL user too, if the URL names '
348
+ + 'one; a server that answers this way refuses every retry, so the connector stopped '
349
+ + 'instead of looping. Only the endpoint is named here; the URL carries the password.',
350
+ { cause: refusal }
351
+ );
352
+ }
353
+ throw new Error(
354
+ `[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 `
356
+ + 'listening at that endpoint and that the host and port in REDIS_URL '
357
+ + '(api/config/env-active/shared.env) are the right ones. A refused credential answers '
358
+ + 'NOAUTH, WRONGPASS or NOPERM and is reported as such, so this is not one. Only the '
359
+ + 'endpoint is named here; the URL carries the password.',
360
+ { cause: reported }
361
+ );
362
+ }
363
+
92
364
  this.connected = true;
93
365
  return true;
94
366
  }
@@ -104,15 +376,23 @@ class SecretsConnector {
104
376
  /**
105
377
  * Resolve a secret ref to plaintext for the given invocation scope.
106
378
  * @param {string} name - opaque secret ref
107
- * @param {{ tenant_id: number|string, workspace_id?: number|string|null }} [scope]
379
+ * @param {{ tenant_id: number|string, workspace_id: number|string }} scope
380
+ * Both ids are required; `workspace_id` must be a positive integer.
108
381
  * @returns {Promise<string>}
109
382
  */
110
383
  async get(name, scope = {}) {
111
384
  if (typeof name !== 'string' || name.length === 0) {
112
- throw new SecretResolutionError('SECRET_REF_INVALID', '[conn-infra-secrets] secret ref must be a non-empty string.');
385
+ throw new SecretResolutionError(
386
+ 'SECRET_REF_INVALID',
387
+ '[conn-infra-secrets] secret ref must be a non-empty string - Expected the ref name declared in '
388
+ + 'the SecretBox. Fix: pass the ref, not the secret value.'
389
+ );
113
390
  }
114
391
  if (!this.client || !this.connected) {
115
- throw new SecretResolutionError('SECRET_STORE_UNAVAILABLE', '[conn-infra-secrets] not connected - call connect() first.');
392
+ throw new SecretResolutionError(
393
+ 'SECRET_STORE_UNAVAILABLE',
394
+ '[conn-infra-secrets] not connected - Expected an open Redis connection. Fix: call connect() first.'
395
+ );
116
396
  }
117
397
  const key = projectionKey(scope.tenant_id, scope.workspace_id, name, this._keyPrefix);
118
398
  const blob = await this.client.get(key);
@@ -123,11 +403,29 @@ class SecretsConnector {
123
403
  );
124
404
  }
125
405
  try {
126
- return open(blob, this._key);
406
+ // The projection key carries no version, so the version comes from the blob
407
+ // and is bound into the context with it — src/crypto.js says what that does
408
+ // and does not prove.
409
+ return openWithDeclaredVersion(blob, this._keyring, {
410
+ tenant_id: scope.tenant_id,
411
+ workspace_id: scope.workspace_id,
412
+ ref: name
413
+ });
127
414
  } catch (err) {
415
+ if (err instanceof SecretCryptoError && err.code === 'SECRET_KEY_UNKNOWN') {
416
+ throw new SecretResolutionError(
417
+ 'SECRET_KEY_UNKNOWN',
418
+ `[conn-infra-secrets] Unknown master key for secret "${name}" at ${key} - ${err.message}`,
419
+ { cause: err }
420
+ );
421
+ }
128
422
  throw new SecretResolutionError(
129
423
  'SECRET_DECRYPT_FAILED',
130
- `[conn-infra-secrets] Failed to decrypt secret "${name}" for scope ${key} - master key mismatch or corrupted blob.`
424
+ `[conn-infra-secrets] Failed to decrypt secret "${name}" for scope ${key} - the blob does not `
425
+ + 'open under the master key it names: it is corrupt, or it was sealed for another tenant, '
426
+ + `workspace or ref. Fix: check that ${MASTER_KEYS_ENV} holds the key this value was sealed `
427
+ + 'with, then re-seal the value via the biz-meta set-secret operation.',
428
+ { cause: err }
131
429
  );
132
430
  }
133
431
  }
@@ -149,7 +447,13 @@ class MockSecretsConnector {
149
447
  isConnected() { return this.connected; }
150
448
  async get(name, scope = {}) {
151
449
  const v = this.store.get(MockSecretsConnector._k(scope, name));
152
- if (v === undefined) throw new SecretResolutionError('SECRET_NOT_FOUND', `mock: ${name} not found`);
450
+ if (v === undefined) {
451
+ throw new SecretResolutionError(
452
+ 'SECRET_NOT_FOUND',
453
+ `[conn-infra-secrets] Secret ref "${name}" not found in MockSecretsConnector - Expected a value `
454
+ + 'seeded for this scope. Fix: call seed(scope, name, value) before get() in the test.'
455
+ );
456
+ }
153
457
  return v;
154
458
  }
155
459
  }
@@ -159,4 +463,5 @@ module.exports.SecretsConnector = SecretsConnector;
159
463
  module.exports.MockSecretsConnector = MockSecretsConnector;
160
464
  module.exports.SecretResolutionError = SecretResolutionError;
161
465
  module.exports.projectionKey = projectionKey;
466
+ module.exports.normalizeWorkspaceId = normalizeWorkspaceId;
162
467
  module.exports.create = (config) => new SecretsConnector(config);
@@ -0,0 +1,147 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Readability — the question "is anything still sealed with a key I am about to
5
+ * drop", asked on purpose instead of by accident.
6
+ *
7
+ * Owner decision 2026-09-14, confirmation
8
+ * api/docs/governance/confirmations/secretbox-master-keys.md 001: a readability
9
+ * check — every stored blob opens with the keys that would REMAIN — is a mandatory
10
+ * step BEFORE a key is removed from `SECRETS_MASTER_KEYS`.
11
+ *
12
+ * WHAT IT COSTS TO SKIP IT, measured: in August 2026 the master key was replaced
13
+ * and the old one simply stopped being used. Nobody asked what was still sealed
14
+ * with it. Six of ten rows of `oagen_meta.secret` became unreadable and stayed
15
+ * that way for two weeks, until someone noticed while checking something else.
16
+ * The old key no longer exists anywhere, so those rows are unrecoverable.
17
+ *
18
+ * IT OPENS EVERY BLOB, it does not merely read the `key_id` off the header. A row
19
+ * whose key is listed but whose blob is corrupt, or bound to another tenant,
20
+ * workspace, ref or version, is just as unreadable — and a check that trusted the
21
+ * header would report it as fine.
22
+ *
23
+ * IT NEVER STOPS HALFWAY. A blob that cannot even be parsed is reported as a
24
+ * finding, not raised as an exception: the whole point is the complete list of
25
+ * what will not open, and a check that dies on the first bad row cannot give one.
26
+ * What it DOES refuse is a record it cannot ask the question about at all — one
27
+ * that does not name its tenant, workspace, ref or version — because that is a
28
+ * defect in the caller's query, not a finding about the store.
29
+ *
30
+ * @see src/rotation.js — the run that makes the list shorter safely
31
+ * @see src/crypto.js — the sealing contract; `keyring.without(id)` builds the list
32
+ * a removal is checked against
33
+ */
34
+
35
+ const { MasterKeyring, SecretCryptoError, open, parseBlob } = require('./crypto');
36
+
37
+ const IDENTITY_FIELDS = ['tenant_id', 'workspace_id', 'ref', 'version'];
38
+
39
+ /** Why a row does not open. Three causes, three different repairs. */
40
+ const REASONS = Object.freeze({
41
+ /** The blob names a key that is not in the list — put it back, or rewrite the value. */
42
+ KEY_NOT_IN_LIST: 'key_not_in_list',
43
+ /** The key is there and the blob still does not open — corrupt, or bound to another row. */
44
+ DOES_NOT_OPEN: 'does_not_open',
45
+ /** Not a blob of the current format at all — rewrite the value from its source. */
46
+ MALFORMED_BLOB: 'malformed_blob'
47
+ });
48
+
49
+ function refuse(code, problem, detail) {
50
+ throw new SecretCryptoError(code, `[conn-infra-secrets] ${problem} - Fix: ${detail}`);
51
+ }
52
+
53
+ function assertRecord(record, index) {
54
+ if (record === null || typeof record !== 'object') {
55
+ refuse(
56
+ 'SECRET_READABILITY_INVALID',
57
+ `Unusable record at position ${index} - got ${record === null ? 'null' : typeof record}`,
58
+ 'pass the records listSecrets() yields: { tenant_id, workspace_id, ref, version, blob }.'
59
+ );
60
+ }
61
+ for (const field of IDENTITY_FIELDS) {
62
+ if (record[field] === undefined || record[field] === null || String(record[field]).trim() === '') {
63
+ refuse(
64
+ 'SECRET_READABILITY_INVALID',
65
+ `Record at position ${index} carries no ${field} - the check cannot ask whether a row opens `
66
+ + 'without knowing which row it is: all four fields are part of the bound context the value is '
67
+ + 'sealed under',
68
+ `select ${field} alongside the sealed blob.`
69
+ );
70
+ }
71
+ }
72
+ if (typeof record.blob !== 'string' || record.blob === '') {
73
+ refuse(
74
+ 'SECRET_READABILITY_INVALID',
75
+ `Record at position ${index} carries no blob - there is nothing to open`,
76
+ 'select the sealed blob for that row; a store keeping the parts in separate columns joins them '
77
+ + 'with sealBlob() in its adapter.'
78
+ );
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Does every stored blob open with this key list?
84
+ *
85
+ * @param {Object} deps
86
+ * @param {Array<Object>|AsyncIterable<Object>} deps.records the stored records
87
+ * @param {MasterKeyring} deps.keyring the list to ask about — typically
88
+ * `keyring.without('<the key you want to drop>')`
89
+ * @returns {Promise<{ total: number, readable: number, unreadable: Array<{
90
+ * tenant_id, workspace_id, ref, version, key_id: string|null, reason: string }> }>}
91
+ */
92
+ async function checkReadability({ records, keyring } = {}) {
93
+ if (!(keyring instanceof MasterKeyring)) {
94
+ refuse(
95
+ 'SECRET_KEYS_INVALID',
96
+ `Missing keyring - a MasterKeyring is required, got ${keyring === null ? 'null' : typeof keyring}`,
97
+ 'pass the list you intend to keep, e.g. loadMasterKeys(SECRETS_MASTER_KEYS).without("<key id>").'
98
+ );
99
+ }
100
+ if (records === null || records === undefined || typeof records !== 'object') {
101
+ refuse(
102
+ 'SECRET_READABILITY_INVALID',
103
+ `Missing records - an array or an async iterable of stored records is required, got `
104
+ + `${records === null ? 'null' : typeof records}`,
105
+ 'pass what the store adapter listSecrets() yields; an empty store is an empty array.'
106
+ );
107
+ }
108
+
109
+ let total = 0;
110
+ let readable = 0;
111
+ const unreadable = [];
112
+
113
+ for await (const record of records) {
114
+ assertRecord(record, total);
115
+ total += 1;
116
+ const identity = {
117
+ tenant_id: record.tenant_id,
118
+ workspace_id: record.workspace_id,
119
+ ref: record.ref,
120
+ version: record.version
121
+ };
122
+
123
+ let keyId = null;
124
+ try {
125
+ keyId = parseBlob(record.blob).keyId;
126
+ } catch {
127
+ unreadable.push({ ...identity, key_id: null, reason: REASONS.MALFORMED_BLOB });
128
+ continue;
129
+ }
130
+ if (!keyring.has(keyId)) {
131
+ unreadable.push({ ...identity, key_id: keyId, reason: REASONS.KEY_NOT_IN_LIST });
132
+ continue;
133
+ }
134
+ try {
135
+ // Opened, not merely inspected: this is the only answer that counts. The
136
+ // plaintext is discarded on the same line it is produced.
137
+ open(record.blob, keyring, identity);
138
+ readable += 1;
139
+ } catch {
140
+ unreadable.push({ ...identity, key_id: keyId, reason: REASONS.DOES_NOT_OPEN });
141
+ }
142
+ }
143
+
144
+ return { total, readable, unreadable };
145
+ }
146
+
147
+ module.exports = { checkReadability, REASONS };