@onlineapps/conn-infra-secrets 1.0.0 → 2.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,43 +9,87 @@
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
42
  class SecretResolutionError extends Error {
29
- constructor(code, message) {
30
- super(message);
43
+ constructor(code, message, options) {
44
+ super(message, options);
31
45
  this.name = 'SecretResolutionError';
32
46
  this.code = code;
33
47
  }
34
48
  }
35
49
 
50
+ // The rule "which workspace_id can address a secret" moved into its own module,
51
+ // src/workspaceId.js: src/crypto.js needs the same normalization for the bound
52
+ // context (AAD), and a second copy of the rule is exactly what
53
+ // .claude/rules/change-discipline.md § One rail per concern forbids. It stays
54
+ // part of this package's public API — re-exported at the bottom of this file —
55
+ // so the writing side keeps importing it from here.
56
+
57
+ /**
58
+ * @param {*} workspaceId
59
+ * @returns {string} the key segment for one concrete workspace
60
+ * @throws {SecretResolutionError} SECRET_SCOPE_MISSING for any unusable value
61
+ */
62
+ function workspaceSegment(workspaceId) {
63
+ const ws = normalizeWorkspaceId(workspaceId);
64
+ if (ws === null) {
65
+ throw new SecretResolutionError(
66
+ 'SECRET_SCOPE_MISSING',
67
+ `[conn-infra-secrets] Invalid workspace_id "${workspaceId}" - Expected a positive integer naming `
68
+ + 'the workspace the secret belongs to. Fix: pass the concrete workspace_id; the tenant-wide '
69
+ + 'scope was retired by owner decision 2026-09-03 '
70
+ + '(api/docs/governance/confirmations/secretbox-tenant-wide-scope.md 001).'
71
+ );
72
+ }
73
+ return String(ws);
74
+ }
75
+
36
76
  function projectionKey(tenantId, workspaceId, ref, keyPrefix = DEFAULT_KEY_PREFIX) {
37
77
  if (tenantId === undefined || tenantId === null) {
38
- throw new SecretResolutionError('SECRET_SCOPE_MISSING', '[conn-infra-secrets] tenant_id is required to resolve a secret.');
78
+ throw new SecretResolutionError(
79
+ 'SECRET_SCOPE_MISSING',
80
+ '[conn-infra-secrets] tenant_id is required to resolve a secret - Expected the tenant the secret '
81
+ + 'belongs to. Fix: pass scope.tenant_id from the operation context.'
82
+ );
39
83
  }
40
- const ws = (workspaceId === null || workspaceId === undefined) ? '-' : String(workspaceId);
41
- return `${keyPrefix}${tenantId}:${ws}:${ref}`;
84
+ return `${keyPrefix}${tenantId}:${workspaceSegment(workspaceId)}:${ref}`;
42
85
  }
43
86
 
44
87
  class SecretsConnector {
45
88
  /**
46
89
  * @param {Object} config
47
90
  * @param {string} config.redisUrl - redis://host:port (required)
48
- * @param {string} config.masterKeyBase64 - base64 32-byte AES-256-GCM key (required)
91
+ * @param {string} config.masterKeys - the declared list, `<key_id>:<base64 32-byte key>[,…]`
92
+ * (required; the value of SECRETS_MASTER_KEYS, first entry = the active key)
49
93
  * @param {string} [config.password] - Redis password
50
94
  * @param {number} [config.db=0] - Redis database index
51
95
  */
@@ -53,7 +97,7 @@ class SecretsConnector {
53
97
  if (!config.redisUrl) {
54
98
  throw new Error('[conn-infra-secrets] redisUrl is required - Fix: pass REDIS_URL.');
55
99
  }
56
- this._key = loadMasterKey(config.masterKeyBase64);
100
+ this._keyring = loadMasterKeys(config.masterKeys);
57
101
  this._keyPrefix = config.keyPrefix || DEFAULT_KEY_PREFIX;
58
102
 
59
103
  let host = config.redisUrl;
@@ -88,30 +132,56 @@ class SecretsConnector {
88
132
  /**
89
133
  * Resolve a secret ref to plaintext for the given invocation scope.
90
134
  * @param {string} name - opaque secret ref
91
- * @param {{ tenant_id: number|string, workspace_id?: number|string|null }} [scope]
135
+ * @param {{ tenant_id: number|string, workspace_id: number|string }} scope
136
+ * Both ids are required; `workspace_id` must be a positive integer.
92
137
  * @returns {Promise<string>}
93
138
  */
94
139
  async get(name, scope = {}) {
95
140
  if (typeof name !== 'string' || name.length === 0) {
96
- throw new SecretResolutionError('SECRET_REF_INVALID', '[conn-infra-secrets] secret ref must be a non-empty string.');
141
+ throw new SecretResolutionError(
142
+ 'SECRET_REF_INVALID',
143
+ '[conn-infra-secrets] secret ref must be a non-empty string - Expected the ref name declared in '
144
+ + 'the SecretBox. Fix: pass the ref, not the secret value.'
145
+ );
97
146
  }
98
147
  if (!this.client || !this.connected) {
99
- throw new SecretResolutionError('SECRET_STORE_UNAVAILABLE', '[conn-infra-secrets] not connected - call connect() first.');
148
+ throw new SecretResolutionError(
149
+ 'SECRET_STORE_UNAVAILABLE',
150
+ '[conn-infra-secrets] not connected - Expected an open Redis connection. Fix: call connect() first.'
151
+ );
100
152
  }
101
153
  const key = projectionKey(scope.tenant_id, scope.workspace_id, name, this._keyPrefix);
102
154
  const blob = await this.client.get(key);
103
155
  if (blob === null || blob === undefined) {
104
156
  throw new SecretResolutionError(
105
157
  'SECRET_NOT_FOUND',
106
- `[conn-infra-secrets] Secret ref "${name}" not found for scope ${key} - Fix: set it via the api_secrets admin API.`
158
+ `[conn-infra-secrets] Secret ref "${name}" not found for scope ${key} - Fix: set it via the biz-meta set-secret operation.`
107
159
  );
108
160
  }
109
161
  try {
110
- return open(blob, this._key);
162
+ // The projection key carries no version, so the version comes from the blob
163
+ // and is bound into the context with it — src/crypto.js says what that does
164
+ // and does not prove.
165
+ return openWithDeclaredVersion(blob, this._keyring, {
166
+ tenant_id: scope.tenant_id,
167
+ workspace_id: scope.workspace_id,
168
+ ref: name
169
+ });
111
170
  } catch (err) {
171
+ if (err instanceof SecretCryptoError && err.code === 'SECRET_KEY_UNKNOWN') {
172
+ throw new SecretResolutionError(
173
+ 'SECRET_KEY_UNKNOWN',
174
+ `[conn-infra-secrets] Unknown master key for secret "${name}" at ${key} - ${err.message}`,
175
+ { cause: err }
176
+ );
177
+ }
112
178
  throw new SecretResolutionError(
113
179
  'SECRET_DECRYPT_FAILED',
114
- `[conn-infra-secrets] Failed to decrypt secret "${name}" for scope ${key} - master key mismatch or corrupted blob.`
180
+ `[conn-infra-secrets] Failed to decrypt secret "${name}" for scope ${key} - the blob does not `
181
+ + 'open under the master key it names: it is corrupt, or it was sealed for another tenant, '
182
+ + `workspace or ref. Fix: check that ${MASTER_KEYS_ENV} holds the key this value was sealed `
183
+ + 'with, then re-seal the value via the biz-meta set-secret operation.',
184
+ { cause: err }
115
185
  );
116
186
  }
117
187
  }
@@ -125,8 +195,7 @@ class SecretsConnector {
125
195
  class MockSecretsConnector {
126
196
  constructor() { this.store = new Map(); this.connected = true; }
127
197
  static _k(scope, name) {
128
- const ws = (scope.workspace_id === null || scope.workspace_id === undefined) ? '-' : String(scope.workspace_id);
129
- return `secret:${scope.tenant_id}:${ws}:${name}`;
198
+ return `secret:${scope.tenant_id}:${workspaceSegment(scope.workspace_id)}:${name}`;
130
199
  }
131
200
  seed(scope, name, value) { this.store.set(MockSecretsConnector._k(scope, name), value); return this; }
132
201
  async connect() { this.connected = true; return true; }
@@ -134,7 +203,13 @@ class MockSecretsConnector {
134
203
  isConnected() { return this.connected; }
135
204
  async get(name, scope = {}) {
136
205
  const v = this.store.get(MockSecretsConnector._k(scope, name));
137
- if (v === undefined) throw new SecretResolutionError('SECRET_NOT_FOUND', `mock: ${name} not found`);
206
+ if (v === undefined) {
207
+ throw new SecretResolutionError(
208
+ 'SECRET_NOT_FOUND',
209
+ `[conn-infra-secrets] Secret ref "${name}" not found in MockSecretsConnector - Expected a value `
210
+ + 'seeded for this scope. Fix: call seed(scope, name, value) before get() in the test.'
211
+ );
212
+ }
138
213
  return v;
139
214
  }
140
215
  }
@@ -144,4 +219,5 @@ module.exports.SecretsConnector = SecretsConnector;
144
219
  module.exports.MockSecretsConnector = MockSecretsConnector;
145
220
  module.exports.SecretResolutionError = SecretResolutionError;
146
221
  module.exports.projectionKey = projectionKey;
222
+ module.exports.normalizeWorkspaceId = normalizeWorkspaceId;
147
223
  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 };
@@ -0,0 +1,206 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Re-encryption — the half of a master-key rotation that carries the rows across.
5
+ *
6
+ * WHY IT EXISTS. Re-encrypting the stored rows is a NECESSARY PART of rotating the
7
+ * master key, not a follow-up (owner decision 2026-09-09,
8
+ * api/docs/governance/confirmations/secret-leak-rotation.md 001), and with a list
9
+ * of keys it is the step that lets the outgoing key be dropped at all. There is
10
+ * deliberately no lazy re-encryption on write to lean on: a row nobody writes to
11
+ * would stay on the old key forever, so "zero rows on the old key" would never
12
+ * hold (owner decision 2026-09-14, `secretbox-master-keys` 001). The August 2026
13
+ * rotation is what that costs: the key was replaced, the rows were left alone, and
14
+ * six of ten rows of `oagen_meta.secret` were unreadable for two weeks.
15
+ *
16
+ * WHAT IT DOES NOT DO. It owns no store. The rows come from an injected repository
17
+ * and go back through it (principle 1, DI) — this package has no database and must
18
+ * not grow one; the adapter over `oagen_<svc>.secret` belongs to the service that
19
+ * owns those rows.
20
+ *
21
+ * THE STORE IS CLASSIFIED BEFORE THE FIRST WRITE. A store where some rows answer
22
+ * to the outgoing key and some to the incoming one is worse than one that answers
23
+ * wholly to either, so everything that can be refused while nothing has been
24
+ * written is refused there — a malformed row, a missing identity, a blob that is
25
+ * not a blob. Classification needs no key: the blob names its own.
26
+ *
27
+ * THE PLAINTEXT LIVES BETWEEN ONE open() AND THE seal() ON THE NEXT LINE. Nothing
28
+ * this module returns, renders or throws carries a value or a key — pinned by a
29
+ * control case in tests/unit/rotation.test.js rather than left to a reader's care.
30
+ *
31
+ * @see src/crypto.js — the sealing contract and the key list
32
+ * @see src/readability.js — the check that has to be green before a key is dropped
33
+ */
34
+
35
+ const { MasterKeyring, SecretCryptoError, open, seal, parseBlob } = require('./crypto');
36
+
37
+ const IDENTITY_FIELDS = ['tenant_id', 'workspace_id', 'ref', 'version'];
38
+
39
+ function refuse(code, problem, detail) {
40
+ throw new SecretCryptoError(code, `[conn-infra-secrets] ${problem} - Fix: ${detail}`);
41
+ }
42
+
43
+ /**
44
+ * The repository this tool needs, checked before it reads anything.
45
+ *
46
+ * `listSecrets()` returns every stored record — an array or an async iterable, so
47
+ * a store that streams does not have to materialize its table.
48
+ * `rewriteSecret(record, blob)` writes the new blob onto THAT row: same tenant,
49
+ * same workspace, same ref, SAME version. The blob is bound to the record's
50
+ * version (the AAD covers it), so a store that answered by creating a new version
51
+ * would write a blob bound to the wrong one.
52
+ */
53
+ function assertRepository(repository) {
54
+ if (repository === null || typeof repository !== 'object') {
55
+ refuse(
56
+ 'SECRET_ROTATION_INVALID',
57
+ `Missing repository - a store exposing listSecrets() and rewriteSecret() is required, got `
58
+ + `${repository === null ? 'null' : typeof repository}`,
59
+ 'inject the adapter over the table that holds the sealed rows; this package owns no store.'
60
+ );
61
+ }
62
+ for (const method of ['listSecrets', 'rewriteSecret']) {
63
+ if (typeof repository[method] !== 'function') {
64
+ refuse(
65
+ 'SECRET_ROTATION_INVALID',
66
+ `Repository cannot serve the rotation - ${method}() is missing`,
67
+ `implement ${method}() on the injected repository: listSecrets() yields every stored record `
68
+ + '{ tenant_id, workspace_id, ref, version, blob }, rewriteSecret(record, blob) writes the new '
69
+ + 'blob onto that same row and version.'
70
+ );
71
+ }
72
+ }
73
+ }
74
+
75
+ function assertKeyring(keyring) {
76
+ if (!(keyring instanceof MasterKeyring)) {
77
+ refuse(
78
+ 'SECRET_KEYS_INVALID',
79
+ `Missing keyring - a MasterKeyring is required, got ${keyring === null ? 'null' : typeof keyring}`,
80
+ 'pass loadMasterKeys(SECRETS_MASTER_KEYS); its FIRST key is the one every row is rewritten under.'
81
+ );
82
+ }
83
+ }
84
+
85
+ /** The identity of one row, in words. Metadata only, never a value. */
86
+ function identity(record) {
87
+ return `tenant ${record.tenant_id} / workspace ${record.workspace_id} "${record.ref}" (v${record.version})`;
88
+ }
89
+
90
+ function assertRecord(record, index) {
91
+ if (record === null || typeof record !== 'object') {
92
+ refuse(
93
+ 'SECRET_ROTATION_INVALID',
94
+ `Unusable record at position ${index} - got ${record === null ? 'null' : typeof record}`,
95
+ 'listSecrets() yields objects { tenant_id, workspace_id, ref, version, blob }.'
96
+ );
97
+ }
98
+ for (const field of IDENTITY_FIELDS) {
99
+ if (record[field] === undefined || record[field] === null || String(record[field]).trim() === '') {
100
+ refuse(
101
+ 'SECRET_ROTATION_INVALID',
102
+ `Record at position ${index} carries no ${field} - every row has to name the tenant, workspace, `
103
+ + 'ref and version it belongs to, because all four are part of the bound context the value is '
104
+ + 'sealed under',
105
+ `select ${field} alongside the sealed blob. The run is refused now, before anything is written: `
106
+ + 'a refusal halfway through leaves the store half rotated.'
107
+ );
108
+ }
109
+ }
110
+ if (typeof record.blob !== 'string' || record.blob === '') {
111
+ refuse(
112
+ 'SECRET_ROTATION_INVALID',
113
+ `Record ${identity(record)} carries no blob - the sealed value is required`,
114
+ 'select the sealed blob for that row; a store keeping the parts in separate columns joins them '
115
+ + 'with sealBlob() in its adapter.'
116
+ );
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Re-encrypt every row that is not yet on the active key.
122
+ *
123
+ * @param {Object} deps
124
+ * @param {{ listSecrets: Function, rewriteSecret: Function }} deps.repository injected store
125
+ * @param {MasterKeyring} deps.keyring the list; its first key is the one rows are rewritten under
126
+ * @returns {Promise<{
127
+ * total: number,
128
+ * active_key_id: string,
129
+ * rewritten: Array<{tenant_id, workspace_id, ref, version, from_key_id, to_key_id}>,
130
+ * skipped: Array<{tenant_id, workspace_id, ref, version, key_id}>,
131
+ * unreadable: Array<{tenant_id, workspace_id, ref, version, key_id}>
132
+ * }>}
133
+ */
134
+ async function reencryptSecrets({ repository, keyring } = {}) {
135
+ assertRepository(repository);
136
+ assertKeyring(keyring);
137
+
138
+ // ---- Phase 1: classify the whole store. Nothing is written here. ----------
139
+ const listed = await repository.listSecrets();
140
+ if (listed === null || typeof listed !== 'object') {
141
+ refuse(
142
+ 'SECRET_ROTATION_INVALID',
143
+ `listSecrets() returned ${listed === null ? 'null' : typeof listed} - an array or an async `
144
+ + 'iterable of records is required',
145
+ 'return the rows; an empty store returns an empty array, never null.'
146
+ );
147
+ }
148
+
149
+ const plan = { rotate: [], skipped: [], unreadable: [] };
150
+ let index = 0;
151
+ for await (const record of listed) {
152
+ assertRecord(record, index);
153
+ index += 1;
154
+ // The blob names its own key, so classification needs no key at all — and no
155
+ // decryption, so no plaintext exists during this phase.
156
+ const { keyId } = parseBlob(record.blob);
157
+ const row = {
158
+ tenant_id: record.tenant_id,
159
+ workspace_id: record.workspace_id,
160
+ ref: record.ref,
161
+ version: record.version,
162
+ key_id: keyId
163
+ };
164
+ if (keyId === keyring.activeKeyId) plan.skipped.push(row);
165
+ else if (keyring.has(keyId)) plan.rotate.push({ row, blob: record.blob });
166
+ else plan.unreadable.push(row);
167
+ }
168
+
169
+ // ---- Phase 2: the only phase that writes. ---------------------------------
170
+ const rewritten = [];
171
+ for (const { row, blob } of plan.rotate) {
172
+ const context = {
173
+ tenant_id: row.tenant_id, workspace_id: row.workspace_id, ref: row.ref, version: row.version
174
+ };
175
+ try {
176
+ // The plaintext exists here and nowhere else: opened on one line, sealed on
177
+ // the next, out of scope after the write.
178
+ const value = open(blob, keyring, context);
179
+ // eslint-disable-next-line no-await-in-loop
180
+ await repository.rewriteSecret({ ...context }, seal(value, keyring, context));
181
+ } catch (error) {
182
+ throw new SecretCryptoError(
183
+ 'SECRET_ROTATION_STOPPED',
184
+ `[conn-infra-secrets] Re-encryption stopped at ${identity(row)} - the store is now PARTLY `
185
+ + `rotated: ${rewritten.length} row(s) already carry "${keyring.activeKeyId}", this row and `
186
+ + 'everything after it still carry the key they had. The run is idempotent: fix the fault and '
187
+ + 'run it again - a row already rewritten is skipped, so a second run finishes exactly what is '
188
+ + `left. Do NOT drop any key from SECRETS_MASTER_KEYS meanwhile. Underlying fault: ${error.message}`,
189
+ { cause: error }
190
+ );
191
+ }
192
+ rewritten.push({ ...row, from_key_id: row.key_id, to_key_id: keyring.activeKeyId });
193
+ }
194
+
195
+ for (const entry of rewritten) delete entry.key_id;
196
+
197
+ return {
198
+ total: plan.rotate.length + plan.skipped.length + plan.unreadable.length,
199
+ active_key_id: keyring.activeKeyId,
200
+ rewritten,
201
+ skipped: plan.skipped,
202
+ unreadable: plan.unreadable
203
+ };
204
+ }
205
+
206
+ module.exports = { reencryptSecrets };
@@ -0,0 +1,38 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Which `workspace_id` can address a secret — the rule, in one place.
5
+ *
6
+ * Every secret belongs to ONE concrete workspace. `workspace_id = 0` is not a
7
+ * value, and the "tenant-wide" scope it used to stand for — projected under a
8
+ * `-` segment — is retired (owner decision 2026-09-03, confirmation
9
+ * secretbox-tenant-wide-scope-001, resolving the doc conflict in favour of
10
+ * api/docs/biz/20-tenancy/multitenancy.md §1.6).
11
+ *
12
+ * Reader and writer apply ONE rule, and this module is where it lives: it is
13
+ * exported from the package's index as part of the public API, so the writing
14
+ * side imports it rather than keeping a second copy (owner decision 2026-09-04 —
15
+ * one rail per concern, .claude/rules/change-discipline.md). A connector library
16
+ * cannot depend on a biz service, so the dependency runs this way round: the
17
+ * library owns the rule, its consumers import it.
18
+ *
19
+ * It sits in its own module rather than in `index.js` because `crypto.js` needs
20
+ * it too: the workspace id is one of the four fields of the bound context (AAD),
21
+ * and a context built with `'3'` on the writing side and `3` on the reading side
22
+ * would be two different contexts — the blob would simply stop opening. One rule,
23
+ * one normalization, both sides.
24
+ */
25
+
26
+ /**
27
+ * @param {*} value
28
+ * @returns {number|null} the normalized id, or null if the value is unusable
29
+ */
30
+ function normalizeWorkspaceId(value) {
31
+ if (typeof value !== 'number' && typeof value !== 'string') return null;
32
+ if (typeof value === 'string' && value.trim() === '') return null;
33
+ const n = Number(value);
34
+ if (!Number.isInteger(n) || n < 1) return null;
35
+ return n;
36
+ }
37
+
38
+ module.exports = { normalizeWorkspaceId };