@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/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog — @onlineapps/conn-infra-secrets
2
+
3
+ All notable changes to this package. Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format.
4
+
5
+ ## [Unreleased]
6
+
7
+ ### Changed — BREAKING
8
+
9
+ - **`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).
10
+ - **New blob layout** `format[1] | keyIdLen[1] | key_id | version[4] | iv[12] | authTag[16] | ciphertext`, with the bound context (AAD) `tenant_id | workspace_id | ref | version`. A blob lifted into another tenant, workspace, ref or version no longer opens. The previous layout is not read — a blob without the format byte is refused (owner decision 2026-09-03, `docs/governance/confirmations/secretbox-blob-format.md` 001).
11
+ - `open()` requires the row's version and compares it with the blob's; the Redis projection reader, which has no version, uses the separately named `openWithDeclaredVersion()`.
12
+
13
+ ### Added
14
+
15
+ - **`reencryptSecrets({ repository, keyring })`** (`src/rotation.js`) — rewrites every stored row that is not yet on the active key, through an injected store adapter (`listSecrets()` / `rewriteSecret(record, blob)`; this package owns no database). Idempotent; the whole store is classified before the first write; a row sealed with a key nobody holds is reported, never written, and does not stop the run. No secret value and no key material reaches its result or its messages.
16
+ - **`checkReadability({ records, keyring })`** (`src/readability.js`) — the mandatory step before a key leaves the list: it OPENS every stored blob with the keys that would remain and reports every row that would not open, with the reason (`key_not_in_list`, `does_not_open`, `malformed_blob`). A blob that cannot be parsed is a finding, not an exception — the point is the complete list. `MasterKeyring.without(id)` builds the list a removal is checked against and refuses to drop the active key.
17
+ - `loadMasterKeys()` + `MasterKeyring` (`activeKeyId`, `keyIds`, `has`, `keyFor`, `activeKey`, `without`), `bindingContext()`, `parseBlob()`, `SecretCryptoError`, and `MASTER_KEYS_ENV` — the one declaration of the configuration key's name and format.
18
+ - `normalizeWorkspaceId` moved to `src/workspaceId.js` (the bound context needs the same rule as the projection key); it stays exported from the package index, unchanged.
19
+
20
+ ## [1.1.0] — 2026-08-29
21
+
22
+ - Changelog started with this release; the history before it is in git (`git log -- shared/connector/conn-infra-secrets`).
package/README.md CHANGED
@@ -1,17 +1,94 @@
1
+ > Status: current
2
+ > Owns: the resolution of `ctx.secrets.get(ref)` — reading the SecretBox Redis projection and decrypting it in-process
3
+
4
+ <!-- BEGIN GENERATED: library-uniform — regenerate: npx oa-sync-template readme-uniform --all -->
5
+ Uniform: [library/connector](../conn-orch-validator/manifests/library.manifest.json)
6
+
7
+ Duty sections that apply:
8
+
9
+ - `all`: L-MAIN, L-ENGINES, L-TESTS, L-TEST-SCRIPT, L-PACK-TESTS, L-PINS, L-NO-FILE-RANGE, L-CHANGELOG, L-README, L-README-REGION, L-CONSUMER
10
+ - `connector`: L-CONNECTOR-ENV
11
+ <!-- END GENERATED: library-uniform -->
12
+
1
13
  # @onlineapps/conn-infra-secrets
2
14
 
3
15
  Resolution connector for `ctx.secrets.get(ref)`. Reads the encrypted Redis
4
16
  projection written by `biz-meta` (SecretBox) and decrypts it in-process with the
5
17
  injected AES-256-GCM master key. Authoritative store (`oagen_meta.secret`) is owned
6
18
  by `biz-meta` and never touched here. Projection key contract:
7
- `state:meta:secret:<tenant>:<workspace|->:<ref>`.
19
+ `state:meta:secret:<tenant>:<workspace>:<ref>`.
20
+
21
+ **Every secret belongs to one concrete workspace.** A usable `workspace_id` is an
22
+ integer >= 1 (as a number or its decimal string); `0`, `'0'`, `null` and an omitted
23
+ value are all **refused** with `SECRET_SCOPE_MISSING`, before any Redis round trip.
24
+ There is no tenant-wide scope and no `-` segment: `workspace_id = 0` is not a value
25
+ (owner decision 2026-09-03,
26
+ [`secretbox-tenant-wide-scope.md`](../../../docs/governance/confirmations/secretbox-tenant-wide-scope.md)
27
+ 001, resolving the doc conflict in favour of
28
+ [`multitenancy.md`](../../../docs/biz/20-tenancy/multitenancy.md) §1.6). Reader and
29
+ writer apply ONE rule, and this package owns it: `normalizeWorkspaceId` is exported
30
+ (see **Public API** below) so the writing side imports it instead of keeping a second
31
+ copy (owner decision 2026-09-04 — one rail per concern,
32
+ [`change-discipline.md`](../../../../.claude/rules/change-discipline.md)). A key this
33
+ reader refuses to build is therefore a key nothing writes.
34
+
35
+ This package also owns the **sealing contract** (`src/crypto.js`): `seal()` /
36
+ `encrypt()` + `sealBlob()` for the writing side, `open()` for every reader. Both
37
+ directions in one module so seal→open is tested across the real boundary, not
38
+ against a reimplementation.
39
+
40
+ ```
41
+ format[1]=0x02 | keyIdLen[1] | key_id | version[4 BE] | iv[12] | authTag[16] | ciphertext
42
+ AAD = "<tenant_id>|<workspace_id>|<ref>|<version>"
43
+ ```
44
+
45
+ The blob **names the key that sealed it**, so a list of keys opens it without
46
+ guessing, and the **bound context (AAD)** ties the ciphertext to the one row it
47
+ belongs to: a blob lifted into another tenant, workspace, ref or version does not
48
+ open, even with the right key (owner decision 2026-09-03,
49
+ [`secretbox-blob-format.md`](../../../docs/governance/confirmations/secretbox-blob-format.md)
50
+ 001). The layout before `key_id` is **not read** — a blob without the format byte
51
+ is refused, never guessed at; no reader for it survived the migration.
52
+
53
+ ## Master keys — a list, and a rotation that is a switch
54
+
55
+ `SECRETS_MASTER_KEYS` is an **ordered list**, and this package owns both its name
56
+ and its format (owner decision 2026-09-14,
57
+ [`secretbox-master-keys.md`](../../../docs/governance/confirmations/secretbox-master-keys.md)
58
+ 001):
59
+
60
+ ```
61
+ SECRETS_MASTER_KEYS="k3:<base64 32-byte key>,k2:<base64 32-byte key>"
62
+ ```
63
+
64
+ - the **first** entry is the active key — every new write is sealed with it;
65
+ - **every** entry decrypts, selected by the blob's own `key_id`;
66
+ - a blob whose `key_id` is not in the list is **refused** (`SECRET_KEY_UNKNOWN`).
67
+ There is no "try every key" pass: it would be the silent fallback
68
+ [`architecture-principles.md`](../../../../.claude/rules/architecture-principles.md)
69
+ §3 forbids, and it would leave the readability check with nothing to measure.
70
+
71
+ So a rotation is a **switch of the list**, not a jump: put the incoming key first,
72
+ re-encrypt (`reencryptSecrets`), check that everything still opens without the
73
+ outgoing key (`checkReadability`), and only then drop it. There is deliberately
74
+ **no lazy re-encryption on write** — a row nobody writes to would stay on the old
75
+ key forever, so "zero unreadable rows" would never hold. What that costs when it
76
+ is skipped is measured: the August 2026 rotation replaced the key and left the
77
+ rows alone, and six of ten rows of `oagen_meta.secret` were unreadable for two
78
+ weeks before anyone noticed.
79
+
80
+ The keys themselves are **injected, never read from the environment here**
81
+ (principle 1, and the connector duty `L-CONNECTOR-ENV`): the consumer reads
82
+ `SECRETS_MASTER_KEYS` and hands the string to `loadMasterKeys()` or to the
83
+ connector's `masterKeys` option. `MASTER_KEYS_ENV` is exported so the name lives
84
+ in one place.
8
85
 
9
86
  Canonical design: [`api/docs/architecture/secretbox.md`](../../../docs/architecture/secretbox.md).
10
87
 
11
88
  ## Usage (wired by ServiceWrapper)
12
89
  ```js
13
90
  const SecretsConnector = require('@onlineapps/conn-infra-secrets');
14
- const secrets = new SecretsConnector({ redisUrl: process.env.REDIS_URL, masterKeyBase64: process.env.SECRETS_MASTER_KEY });
91
+ const secrets = new SecretsConnector({ redisUrl: process.env.REDIS_URL, masterKeys: process.env.SECRETS_MASTER_KEYS });
15
92
  await secrets.connect();
16
93
  // ContextBuilder facade calls: secrets.get(ref, { tenant_id, workspace_id })
17
94
  ```
@@ -19,10 +96,169 @@ await secrets.connect();
19
96
  Handlers only ever call `ctx.secrets.get(ref)`; the ContextBuilder facade injects
20
97
  the invocation scope.
21
98
 
99
+ ## Public API
100
+ ```js
101
+ const SecretsConnector = require('@onlineapps/conn-infra-secrets'); // default = the connector
102
+ const {
103
+ SecretsConnector, MockSecretsConnector, SecretResolutionError,
104
+ projectionKey, normalizeWorkspaceId, create,
105
+ } = require('@onlineapps/conn-infra-secrets');
106
+
107
+ // the sealing contract and the key list
108
+ const {
109
+ loadMasterKeys, MasterKeyring, SecretCryptoError, MASTER_KEYS_ENV,
110
+ bindingContext, seal, open, openWithDeclaredVersion,
111
+ encrypt, decrypt, sealBlob, parseBlob,
112
+ } = require('@onlineapps/conn-infra-secrets/src/crypto');
113
+ ```
114
+
115
+ ### `loadMasterKeys(spec) => MasterKeyring`
116
+ Parses `SECRETS_MASTER_KEYS`. The keyring is immutable and answers
117
+ `activeKeyId`, `keyIds`, `size`, `has(id)`, `keyFor(id)`, `activeKey()` and
118
+ `without(id)` — the last one is the list a key removal is checked against, and it
119
+ refuses to drop the active key. Every refusal names the problem and the fix, and
120
+ **no message ever carries key material**.
121
+
122
+ ### `seal(value, keyring, context)` / `open(blob, keyring, context)`
123
+ `context` is the row: `{ tenant_id, workspace_id, ref, version }`. `open()`
124
+ compares the context's version with the blob's and refuses a mismatch — so
125
+ renumbering a version is a re-encryption, the cost accepted in
126
+ `secretbox-blob-format` 001.
127
+
128
+ ### `openWithDeclaredVersion(blob, keyring, { tenant_id, workspace_id, ref })`
129
+ For the Redis projection, whose key (`…:<tenant>:<workspace>:<ref>`) carries no
130
+ version: the version is taken from the blob and bound into the context with it.
131
+ It is a separate name, not an optional argument, because it proves strictly less
132
+ — the tenant, workspace and ref are still bound, the version is the blob's own
133
+ claim.
134
+
135
+ ### `normalizeWorkspaceId(value) => number|null`
136
+ The rule "which `workspace_id` can address a secret", in one place. Accepts an
137
+ integer >= 1 as a number or as its decimal string and returns it **as a number**;
138
+ every other value — `0`, `'0'`, negative, fractional, non-numeric, empty/blank
139
+ string, `null`, `undefined`, boolean, `NaN`, `Infinity`, object, array — is
140
+ unusable and yields `null`.
141
+
142
+ It **never throws**: refusal is a return value, so each caller decides what
143
+ refusing means. This connector turns it into `SECRET_SCOPE_MISSING` before any
144
+ Redis round trip (`projectionKey`); a writer of the projection refuses to build
145
+ the key at all. Import it — do not reimplement it: a second copy of this rule is
146
+ a defect by default ([`change-discipline.md`](../../../../.claude/rules/change-discipline.md)
147
+ § One rail per concern).
148
+
149
+ ```js
150
+ const { normalizeWorkspaceId } = require('@onlineapps/conn-infra-secrets');
151
+ normalizeWorkspaceId(7); // 7
152
+ normalizeWorkspaceId('10'); // 10
153
+ normalizeWorkspaceId('0'); // null → the caller must refuse
154
+ ```
155
+
156
+ ## Re-encryption — `reencryptSecrets({ repository, keyring })`
157
+
158
+ The step that makes the outgoing key droppable. It reads every stored record,
159
+ rewrites the ones still on an old key under the **active** key, and reports the
160
+ ones it cannot open.
161
+
162
+ ```js
163
+ const { loadMasterKeys } = require('@onlineapps/conn-infra-secrets/src/crypto');
164
+ const { reencryptSecrets } = require('@onlineapps/conn-infra-secrets/src/rotation');
165
+
166
+ const result = await reencryptSecrets({
167
+ keyring: loadMasterKeys(process.env.SECRETS_MASTER_KEYS),
168
+ repository, // the store adapter — see below
169
+ });
170
+ // { total, active_key_id, rewritten: [...], skipped: [...], unreadable: [...] }
171
+ ```
172
+
173
+ **The store is injected** (principle 1): this package owns no database, and the
174
+ adapter over `oagen_<service>.secret` belongs to the service that owns those rows.
175
+ It has to provide exactly two methods:
176
+
177
+ | method | contract |
178
+ |---|---|
179
+ | `listSecrets()` | every stored record `{ tenant_id, workspace_id, ref, version, blob }` — an array or an async iterable, so a store that streams need not materialize its table |
180
+ | `rewriteSecret(record, blob)` | write the new blob onto **that same row and version**. The blob is bound to the record's version (the AAD covers it), so a store that answered by creating a NEW version would write a blob bound to the wrong one |
181
+
182
+ Properties, each pinned by a test:
183
+
184
+ - **Idempotent** — a second run writes nothing; rows already on the active key are
185
+ skipped, so a run interrupted by a failing store is finished simply by running
186
+ it again.
187
+ - **The whole store is classified before the first write** — a malformed record or
188
+ an unreadable blob refuses the run while nothing has been written, because a
189
+ half-rotated store answers wholly to no key.
190
+ - **A row on a key nobody holds is reported, never written, and does not stop the
191
+ run** — it was already unreadable before the rotation started, and it needs its
192
+ value rewritten from its source. (The six such rows of `oagen_meta.secret` are
193
+ `biz-meta`'s data decision.)
194
+ - **Nothing it returns or throws carries a secret value or key material.**
195
+
196
+ ## Readability — `checkReadability({ records, keyring })`
197
+
198
+ The mandatory step **before a key is removed from the list**: it answers, for a
199
+ given key list, whether every stored blob still opens.
200
+
201
+ ```js
202
+ const { checkReadability } = require('@onlineapps/conn-infra-secrets/src/readability');
203
+
204
+ const keyring = loadMasterKeys(process.env.SECRETS_MASTER_KEYS);
205
+ const result = await checkReadability({ records, keyring: keyring.without('k1') });
206
+ // { total, readable, unreadable: [{ tenant_id, workspace_id, ref, version, key_id, reason }] }
207
+ // drop "k1" only while result.unreadable is empty
208
+ ```
209
+
210
+ `records` are the same records `listSecrets()` yields. The check **opens** every
211
+ blob rather than only comparing its `key_id`, so a row whose key is listed but
212
+ whose blob is corrupt or bound to another context is reported too. A blob that
213
+ cannot be parsed at all is a finding (`malformed_blob`), never an exception: the
214
+ point is the complete list of what will not open. Nothing it returns carries a
215
+ value or a key.
216
+
217
+ The order of a rotation, then, with nothing left to remember:
218
+
219
+ 1. put the incoming key FIRST in `SECRETS_MASTER_KEYS`, keep the outgoing one;
220
+ 2. `reencryptSecrets(...)` — every row moves to the incoming key;
221
+ 3. `checkReadability({ records, keyring: keyring.without('<outgoing id>') })` —
222
+ it must report `unreadable: []`;
223
+ 4. only then remove the outgoing key from `SECRETS_MASTER_KEYS`.
224
+
225
+ Step 3 is what was missing in August 2026, and skipping it is what left six rows
226
+ of `oagen_meta.secret` sealed with a key that no longer exists anywhere.
227
+
22
228
  ## Errors (no fallbacks)
23
229
  - `SECRET_NOT_FOUND` — no projection for the ref/scope.
24
- - `SECRET_DECRYPT_FAILED` — master key mismatch or corrupted blob.
25
- - `SECRET_SCOPE_MISSING` / `SECRET_REF_INVALID` — bad call.
230
+ - `SECRET_KEY_UNKNOWN` — the blob names a `key_id` that is not in
231
+ `SECRETS_MASTER_KEYS`. The row is still on a key this service does not hold:
232
+ put it back and re-encrypt, or rewrite the value.
233
+ - `SECRET_DECRYPT_FAILED` — the blob does not open under the key it names:
234
+ corrupt, or sealed for another tenant, workspace or ref.
235
+ - `SECRET_SCOPE_MISSING` — the scope cannot address one concrete secret: missing
236
+ `tenant_id`, or a `workspace_id` that is not a positive integer (`0`, `'0'`,
237
+ `null`, omitted).
238
+ - `SECRET_REF_INVALID` — bad call.
239
+
240
+ Every message opens with `[conn-infra-secrets]`, names the problem and ends with
241
+ the fix — the format of `.claude/rules/architecture-principles.md` §5,
242
+ `[Context] Problem - Expected/Fix`. `SECRET_DECRYPT_FAILED` additionally carries
243
+ the `node:crypto` error as `cause`, which is what separates a wrong master key
244
+ from a truncated blob; the other codes have no cause because nothing under them
245
+ failed.
26
246
 
27
247
  ## Test
28
- `npm run test:unit` — decrypt round-trip, tamper/not-found, scope, mock.
248
+ - `npm run test:unit` — the key list (parsing, refusals, immutability), seal/open
249
+ round trip against a frozen layout fixture built by an outside generator, the
250
+ bound context (a blob moved to another tenant / workspace / ref / version does
251
+ not open), key selection by `key_id` and the refusal of an unlisted one,
252
+ tamper/not-found paths, scope refusal, the exported `normalizeWorkspaceId`
253
+ contract, mock.
254
+ - `npm run test:unit` (rotation/readability) — re-encryption and the readability
255
+ check against REAL crypto over an injected in-memory store: idempotence, the
256
+ classify-before-write order, the row on a dropped key, the failed write, and
257
+ controls that no value and no key material reaches a result or a message.
258
+ - `npm run test:integration` — real Redis. The re-encryption run uses the live
259
+ 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.
@@ -0,0 +1,18 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Separate Jest runner for the integration tier: it carries the `globalSetup`
5
+ * that probes the live Redis and aborts the run when it is absent, so the tier
6
+ * can never report a result on an environment that cannot serve it.
7
+ *
8
+ * @see tests/integration/setup.js
9
+ */
10
+
11
+ module.exports = {
12
+ testEnvironment: 'node',
13
+ testMatch: ['**/tests/integration/**/*.test.js'],
14
+ testTimeout: 30000,
15
+ globalSetup: './tests/integration/setup.js',
16
+ coverageDirectory: 'coverage-integration',
17
+ verbose: true
18
+ };
package/package.json CHANGED
@@ -1,11 +1,15 @@
1
1
  {
2
2
  "name": "@onlineapps/conn-infra-secrets",
3
- "version": "1.0.0",
3
+ "version": "2.0.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
+ "oa": {
6
+ "category": "connector"
7
+ },
5
8
  "main": "src/index.js",
6
9
  "scripts": {
7
- "test": "jest",
8
- "test:unit": "jest tests/unit"
10
+ "test": "npm run test:unit && npm run test:integration",
11
+ "test:unit": "jest tests/unit",
12
+ "test:integration": "jest --config=jest.integration.config.js"
9
13
  },
10
14
  "keywords": [
11
15
  "secrets",
@@ -23,7 +27,7 @@
23
27
  "jest": "^29.7.0"
24
28
  },
25
29
  "engines": {
26
- "node": ">=14.0.0"
30
+ "node": ">=24.0.0 <25"
27
31
  },
28
32
  "publishConfig": {
29
33
  "access": "public",