@onlineapps/service-common 2.0.1 → 3.0.1

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,601 @@
1
+ # Changelog — @onlineapps/service-common
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
+ ## [3.0.1] — 2026-09-15
8
+
9
+ ### Fixed — vadná hodnota jmenuje klíč, ne interní `"value"` (d.466b)
10
+
11
+ Všechny čtyři env helpery (`requireEnv`, `requireTypedEnv` — tedy
12
+ `requireNumberEnv` i `requireFloatEnv` —, `optionalEnv`, `optionalNumberEnv`)
13
+ stavěly resolveru schéma pod interním jménem `value`. Hlášku o vadné hodnotě
14
+ vlastní `@onlineapps/runtime-config` a píše do ní klíč, který dostal, takže
15
+ operátor četl `Invalid number value for "value" (env: WS_MISSED_PONGS_LIMIT)` —
16
+ jméno, které v žádné konfiguraci není. INFRA to viděla při kaskádě 2026-09-15.
17
+ Klíč schématu je nově jméno proměnné prostředí: `Invalid number value for
18
+ "WS_MISSED_PONGS_LIMIT" (env: WS_MISSED_PONGS_LIMIT)`.
19
+
20
+ `requireNumberEnv`/`requireFloatEnv` navíc předávají do schématu `options.file`,
21
+ které se do té doby použilo jen na větu o CHYBĚJÍCÍ proměnné a pro vadnou
22
+ hodnotu zahodilo — obě věty přitom mluví o témž klíči v témž souboru. Místo
23
+ (`in config/env-active/<file>`) se v hlášce objeví s pinem
24
+ `@onlineapps/runtime-config` 1.2.0; pin 1.1.0 ji ještě nenese.
25
+
26
+ Hlášky o CHYBĚJÍCÍ proměnné a veřejné API balíčku se nemění.
27
+
28
+ ### Fixed — redakce se bere z `@onlineapps/logger-contract`, lokální kopie jsou pryč (d.455)
29
+
30
+ Balíček držel dvě vlastní implementace pravidla „co smí dorazit do logu“:
31
+ `src/redactUrl.js` (userinfo z connection URL) a `src/redactSensitive.js`
32
+ (`sensitive_input_fields`, SecretBox F1.5). Obě jsou smazány; `src/index.js`
33
+ je teď **re-exportuje identitou** z `@onlineapps/logger-contract` 1.2.0.
34
+ Veřejné API balíčku se nemění — `redactUrl`, `redactSensitiveDeep`,
35
+ `sensitiveFieldsForOperation` a `REDACTED_PLACEHOLDER` se dál importují odsud,
36
+ protože odsud si je berou `@onlineapps/infrastructure-tools` (a přes něj
37
+ gateway) i monitoring.
38
+
39
+ Čtyři otázky ke smazané deklaraci (`change-discipline.md` § Removing):
40
+
41
+ 1. **Proč vznikla** — obě kopie vznikly dřív, než core vrstva měla domov pro
42
+ toto pravidlo: `redactUrl` v d.245/d.245b (heslo z `REDIS_URL` ve třech
43
+ boot log lines), `redactSensitive` pro SecretBox F1.5. Importovat je
44
+ z vyšší vrstvy nešlo (`architecture-principles.md` §7), takže si každá
45
+ vrstva nesla svou.
46
+ 2. **Která část koncepce je nesla** — `change-discipline.md` § One rail per
47
+ concern je naopak zakazuje: tři vlastníci jednoho pravidla (tady,
48
+ `mq-client-core`, `conn-orch-registry`) a nic, co selže, když se rozejdou.
49
+ Ta část koncepce stojí dál.
50
+ 3. **Proč je dnes nikdo nečte** — `@onlineapps/logger-contract` 1.2.0 (d.453)
51
+ exportuje `redactUrl`, `UNPARSEABLE_PLACEHOLDER`, `redactSensitiveDeep`,
52
+ `sensitiveFieldsForOperation` a `REDACTED_PLACEHOLDER`; spustitelné řádky
53
+ jsou s oběma zdejšími kopiemi shodné (ověřeno `diff`, liší se jen JSDoc).
54
+ 4. **Je náhrada koncepčnější** — ano. `logger-contract` je vrstva L1 bez
55
+ `@onlineapps` závislostí, takže na ni dosáhne každá kategorie balíčků;
56
+ brána G7 naopak odmítá pin `orchestration` → `runtime`, kterým si dřív
57
+ `conn-orch-orchestrator` sahal pro tyto helpery sem.
58
+
59
+ Testy `tests/unit/redactUrl.test.js` a `tests/unit/redactSensitive.test.js`
60
+ odešly s implementací (pokrytí drží `@onlineapps/logger-contract`). Místo nich
61
+ je `tests/unit/redactionReexport.test.js`: re-export je **identita**, lokální
62
+ soubory na disku nejsou, chování (happy path, cesta selhání) dorazí ke
63
+ konzumentovi beze změny, a kontrolní případ hlídá, že vlastní exporty balíčku
64
+ se tím nehnuly.
65
+
66
+ ## [3.0.0] — 2026-09-14
67
+
68
+ ### Fixed — registryReader se připojuje přes `connectRedis`; jeho timeoutová hláška renderovala heslo z `REDIS_URL` (d.441)
69
+
70
+ `registryReader.connect()` měl ve větvi, kde klienta vlastní, **druhou kolej**
71
+ téhož mechanismu jako `connectRedis` v `src/redisClient.js`: vlastní
72
+ `createClient({ url, socket: { connectTimeout } })`, vlastní `Promise.race`
73
+ a vlastní `setTimeout` (`change-discipline.md` § One rail per concern). Rozdíl
74
+ mezi kolejemi nebyl kosmetický:
75
+
76
+ - Hláška při nedostupném Redisu zněla `no connection to ${redisUrl}` — tedy
77
+ **syrová URL včetně credentialu**. Změřeno na HEAD:
78
+ `… no connection to redis://registryreader:integr4tion-s3cr3t@127.0.0.1:1 within 1000ms`.
79
+ Je to táž třída vady, kterou pro `redisClient` zavřela d.245/d.245b
80
+ (`redactUrl`, `tests/unit/redisUrlNotLogged.test.js`), a tyto logy jdou do
81
+ Loki, takže únik je trvalý a vyhledatelný.
82
+ - Lhůta se předávala node-redisu i závodu ve stejné hodnotě, takže pokus
83
+ knihovny končil až PO tvrdém stropu a socket v letu přežíval vlastní
84
+ `disconnect()` — přesně to, co d.437b pro `connectRedis` opravilo
85
+ (`CONNECT_ATTEMPT_SHARE`).
86
+ - `client.on('error')` logoval `${CONTEXT} Redis client error: ${err.message}`
87
+ mimo jednotný tvar `[Redis] Error` s redigovanou URL.
88
+
89
+ Nově reader ve větvi `ownsClient` jen řekne rozpočet a zbytek nechá jediné
90
+ koleji:
91
+
92
+ ```js
93
+ redis = await connectRedis({ purpose: 'registryReader', redisUrl, logger, timeoutMs: connectTimeoutMs });
94
+ ```
95
+
96
+ Navenek se nemění `connectTimeoutMs` (dál strop pro `connect()`, výchozí 15 s),
97
+ volba `redisUrl` vs. `client` ani chování s injektovaným klientem (ten reader
98
+ neotvírá a `close()` ho nikdy nezavírá). Mění se **znění hlášky** — jednotný
99
+ kontraktní tvar `[service-common][redisClient] Redis connection <verdikt> to
100
+ <endpoint> - <důvod>` s původní chybou jako `cause` a s endpointem bez
101
+ userinfo.
102
+
103
+ **Zpřísnění kontraktu loggeru (breaking pro volajícího s neúplným loggerem):**
104
+ reader si dosud ověřoval vlastní třímetodovou variantu (`info`/`warn`/`error`)
105
+ — poslední lokální kopii kontraktu v tomto balíčku. Klient, kterého reader
106
+ vlastní, píše do téhož objektu, takže kontrakt teď vlastní
107
+ `@onlineapps/logger-contract` (`assertLogger`, čtyři metody včetně `debug`,
108
+ konfirmace `connector-logger-contract` 001/004) a ověří se při KONSTRUKCI, ne
109
+ až při `connect()`. Jediný volající mimo balíček — `api_biz/meta/index.js:72` —
110
+ předává `wrapper.logger`, který kontrakt splňuje.
111
+
112
+ Testy: `tests/unit/registryReader.connectRail.test.js` (cesta selhání bez
113
+ credentialu, rozpočet předaný `connectRedis`, výchozí lhůta, KONTROLA úspěšného
114
+ připojení, KONTROLA injektovaného klienta) a nový případ v
115
+ `tests/integration/registryReader.integration.test.js` proti živému Redisu
116
+ (odmítnutý port s credentialem v URL → hláška i log bez hesla). Počet hlášek
117
+ v `tests/unit/error-message-contract.test.js` klesl 42 → 40: zanikla timeoutová
118
+ hláška readeru i jeho `requireLogger`.
119
+
120
+ ### Changed (breaking) — kontrolu zastaralých rolí dělá injektovaná funkce, ne redis klient (d.431)
121
+
122
+ `createJwtValidator({ logger, secret, excludePaths, redisClient })` testoval
123
+ `redisClient.isOpen` a volal `redisClient.get()` — tedy API node-redis v4. Služba,
124
+ jejíž klient je `ioredis` (`infra/api_meta_reader`), kontrolu nemohla dostat vůbec;
125
+ tvar klienta přitom není znalost knihovny (`architecture-principles.md` §1, §8).
126
+
127
+ Nový vstup je funkce a je **povinná**:
128
+
129
+ ```js
130
+ createJwtValidator({ logger, secret, readRolesVersion, excludePaths })
131
+ // readRolesVersion: (personId) => Promise<number|null>
132
+ ```
133
+
134
+ - `null` = marker neexistuje → token platný (beze změny, konfirmace
135
+ `jwt-stale-check-fail-closed` 001).
136
+ - odmítnutí čtenáře **nebo** odpověď, která není konečné číslo ani `null` → 503
137
+ `ROLES_VERSION_CHECK_UNAVAILABLE`, nikdy průchod (táž konfirmace).
138
+ - chybějící `readRolesVersion` → výjimka při KONSTRUKCI: kontrola běží pro každý
139
+ platný token, takže instance, která ji neumí provést, nesmí vzniknout (§4).
140
+ - parametr `redisClient` zaniká celý, bez přechodového tvaru (§11).
141
+
142
+ Klíč čte volající, ale skládá ho z exportovaného `ROLES_VERSION_PREFIX` =
143
+ `state:meta:person:roles_version:` — marker píše biz-meta přes konektor, který
144
+ prefix `state:meta:` přidává, takže čtenář holého `person:roles_version:<id>`
145
+ dostane `null` pro každou osobu a `TOKEN_STALE` nepadne nikdy (rozhodnutí
146
+ vlastníka 2026-09-14, `redis-state-prefix` 002). README § JWT validation nese
147
+ hotový tvar čtenáře.
148
+
149
+ Volající mimo tento balíček pinují vydané 2.0.x a tímto commitem se nemění;
150
+ překlápějí se s pinem příštího vydání: `infra/api_gateway/index.js:621`,
151
+ `infra/api_gateway/routes/protected.js:45`, `infra/api_meta_reader/src/index.js:81`
152
+ (a testy `infra/api_gateway/tests/integration/webhookWorkflowProjection.test.js:219`,
153
+ `infra/api_gateway/tests/unit/authPipelineBootWindow.test.js:88`).
154
+
155
+ ### Fixed — token vydaný ve stejné sekundě jako marker už není TOKEN_STALE (d.430)
156
+
157
+ `iat` je celý počet SEKUND zaokrouhlený dolů (RFC 7519 §4.1.6), marker je epoch
158
+ MILISEKUND (`docs/standards/redis-key-contract.md`). Porovnání `decoded.iat * 1000 <
159
+ versionTs` proto prohlásilo za zastaralý i token vydaný pár milisekund PO změně
160
+ role, kterou už nese — `addMembership` zapíše `String(Date.now())` a `api_auth`
161
+ podepíše nový token v téže sekundě. Držitel dostal pokyn obnovit token a obnovený
162
+ token spadl do téže sekundy znovu.
163
+
164
+ Porovnává se v hrubší z obou jednotek: marker se zaokrouhlí dolů na sekundy
165
+ (`decoded.iat < Math.floor(rolesVersion / 1000)`). Token ze STEJNÉ sekundy je
166
+ platný, token o sekundu starší je dál `TOKEN_STALE` — obojí pokryto v unit i
167
+ integračním tieru proti živému Redisu.
168
+
169
+ ### Fixed — connectRedis uvolní socket na vlastní lhůtě a každé selhání hlásí jednou hláškou (d.437b)
170
+
171
+ `disconnect()` z d.437 uvolní jen socket, který node-redis už přiřadil
172
+ (`RedisSocket#disconnect`); spojení, které je pořád v letu, ho přežije a drží
173
+ otevřený TCPWRAP handle po celou výchozí lhůtu node-redisu (5 s). Změřeno
174
+ 2026-09-14 na unit tieru balíčku: `npx jest --detectOpenHandles` hlásil
175
+ `TCPWRAP … at connect (src/redisClient.js:138)` z
176
+ `tests/unit/redisUrlNotLogged.test.js` a běh končil hláškou `A worker process
177
+ has failed to exit gracefully`.
178
+
179
+ Pokus node-redisu proto dostává lhůtu odvozenou z rozpočtu volajícího
180
+ (`CONNECT_ATTEMPT_SHARE` = 0,8 × `timeoutMs`, tedy 12 s z výchozích 15 s) a
181
+ dostane ji jako `socket.connectTimeout` — socket tak ničí knihovna, která ho
182
+ otevřela, ještě uvnitř stropu (změřeno: 803 ms z 1000ms stropu). Vlastní závod
183
+ na `timeoutMs` zůstává tvrdým celkovým stropem: mimo `forTests` může
184
+ reconnect-strategie zkusit víc pokusů, takže se neruší.
185
+
186
+ Každé selhání spojení teď opouští `connectRedis` jedinou hláškou ve tvaru
187
+ `[Context] Problem - Fix` s původní chybou jako `cause` — dosud prošla
188
+ node-redisí holá `Connection timeout` i `ECONNREFUSED` beze všeho kontextu:
189
+
190
+ - lhůta (naše i ta předaná node-redisu) → `… Redis connection timeout after <timeoutMs>ms to <endpoint> - <důvod>. Fix: …`
191
+ - jiné selhání → `… Redis connection failed to <endpoint> - <důvod>. Fix: …`; odmítnuté spojení není timeout a hláška to o něm netvrdí.
192
+
193
+ Endpoint v hlášce je dál redigovaný (`loggedUrl`), takže credential se ven
194
+ nedostane. Věta `Fix:` nově radí zvýšit `timeoutMs` — dosavadní
195
+ `connectTimeoutMs` je vstup `registryReader`, `connectRedis` takový nikdy
196
+ neměl. `createRedisClient` přijímá nepovinný `connectAttemptTimeoutMs` (lhůta
197
+ JEDNOHO pokusu, ne celkový strop).
198
+
199
+ ### Fixed — connectRedis po neúspěšném spojení uvolní klienta, kterého otevřel (d.437)
200
+
201
+ Prohraje-li `client.connect()` závod s timeoutem, běží dál — s výchozí
202
+ reconnect-strategií donekonečna — a volající klienta na této cestě nikdy
203
+ nedostane, takže ho nemá kdo zavřít. `connectRedis` ho teď zavírá sám
204
+ (`client.disconnect()`, jen když je `isOpen`; selhání zavření se loguje a
205
+ původní chybu nepřekryje).
206
+
207
+ ### Fixed — asserty hlášky chybějící env na znění runtime-config 1.1.0 (d.426)
208
+
209
+ Po pinu `@onlineapps/runtime-config` na 1.1.0 citovaly dva asserty
210
+ v `tests/unit/defaults.test.js` starou větu `Fix: set REDIS_URL in environment or
211
+ pass explicit config.`; nově citují celou hlášku ve znění, které předepisuje
212
+ `architecture-principles.md` §5 — `Fix: set REDIS_URL in env-active/*.env (or pass
213
+ explicit config).`.
214
+
215
+ ### Changed (breaking) — the JWT signing secret is an input, not something the library fetches
216
+
217
+ `verifyAccessToken(token)` resolved the secret itself, per call, from
218
+ `process.env.JWT_SECRET` (`src/jwt/verifyAccessToken.js`, the deleted
219
+ `getJwtSecret()`). A shared library that reads the environment knows where the
220
+ platform keeps its configuration — the dependency nobody declared
221
+ (`architecture-principles.md` §1 dependencies by constructor, §8 explicit over
222
+ implicit; measured by INFRA-DOCS 2026-09-07, recorded in
223
+ `docs/standards/CONCEPT_AUDIT.md` as grey).
224
+
225
+ The secret is now an input on both entry points:
226
+
227
+ - `verifyAccessToken(token, secret)` — second argument, required.
228
+ - `createJwtValidator({ logger, secret, excludePaths, redisClient })` — validated
229
+ at CONSTRUCTION, so an instance can never exist that every request would then
230
+ be refused by (§4 fail-fast).
231
+
232
+ A **value**, not a `getSecret()` function: `docs/standards/JWT_AUTH.md`
233
+ § Configuration distributes one shared secret via env/Docker secrets and knows no
234
+ secret rotation: `grep -in rotat docs/standards/JWT_AUTH.md` returns exactly one
235
+ line, the certificate rotation listed as a COST of the mTLS option §9 rejects. An
236
+ injected `getSecret()` resolver would be a mechanism for a concept that does not
237
+ exist; when rotation is decided, the parameter it replaces is one line.
238
+
239
+ The caller reads the key once at boot, through the helper that composes the whole
240
+ `Fix:` sentence (d.399):
241
+
242
+ ```js
243
+ const secret = requireEnv('JWT_SECRET', 'HMAC-SHA256 signing secret', { file: 'shared.env' });
244
+ app.use(createJwtValidator({ logger, secret, excludePaths, redisClient }));
245
+ ```
246
+
247
+ **The rule about the value stays in the library**, because it is a property of
248
+ the secret and not of where it came from: `assertSecret()` — a string of at least
249
+ `MIN_SECRET_LENGTH` (16) characters, the minimum `JWT_AUTH.md` § Configuration
250
+ states — is the single rail both entry points validate against.
251
+
252
+ Callers outside this package still pin the published 2.0.x and are untouched by
253
+ this commit; they move with the pin of the next release:
254
+ `infra/api_gateway/index.js:599`, `infra/api_gateway/routes/protected.js:19`,
255
+ `infra/api_meta_reader/src/index.js:81` (all `createJwtValidator`),
256
+ `infra/api_delivery_endpoint/src/ws/WebSocketServer.js:124` and
257
+ `infra/api_delivery_endpoint/src/routes/workflows.js:31` (both
258
+ `verifyAccessToken`).
259
+
260
+ #### `getJwtSecret()` deleted — the four questions (`change-discipline.md` § Removing)
261
+
262
+ 1. **Why it existed.** It was the verifier's only way to a secret: with no
263
+ injection point, the library resolved `JWT_SECRET` itself so that every
264
+ service verifying a token got the same key and the same fail-fast on a
265
+ missing or too-short value.
266
+ 2. **Which part of the concept carried it.** `docs/standards/JWT_AUTH.md`
267
+ § Configuration — one shared `JWT_SECRET`, minimum 16 characters, identical
268
+ across every service that validates a JWT — and `gateway-jwt-failfast` 001, a
269
+ boot that dies on an absent key rather than serving unauthenticated traffic.
270
+ Both still stand: the key, the minimum and the fail-fast are unchanged. Only
271
+ the PLACE that reads the environment moves, from the library to the service
272
+ that owns its configuration.
273
+ 3. **Why nothing reads it.** Nothing outside this file ever did: it was absent
274
+ from `src/jwt/index.js` and from `src/index.js`, so no consumer could import
275
+ it (`grep -rn getJwtSecret` over `api`, `api_biz`, `fe_adminui` finds only
276
+ `infra/api_auth/src/tokenService.js:9`, a private function of the same name in
277
+ the token ISSUER, which is a different service and is not touched here). Its
278
+ one reader was `verifyAccessToken`, three lines below it, and that reader now
279
+ receives the value.
280
+ 4. **Is the replacement more conceptual?** Yes. The secret as a parameter is the
281
+ principle the function violated, and the env read lands where the concept
282
+ already puts configuration: at the boot of the owning service, through
283
+ `requireEnv('JWT_SECRET', …, { file: 'shared.env' })`, which names the key,
284
+ the owning env file and the fix in one sentence. A pure `getJwtSecret()`
285
+ kept "without env" would have been a rename of the hole: whoever called it
286
+ would still be asking the library where secrets live.
287
+
288
+ Tests: `tests/unit/jwt/verifyAccessToken.test.js` and
289
+ `tests/unit/jwt/createJwtValidator.test.js` now run with a DIFFERENT valid secret
290
+ in `process.env.JWT_SECRET` throughout, so a re-introduced environment read fails
291
+ the happy path of both files, not only the one case that names it; the same
292
+ control runs against the live Redis in
293
+ `tests/integration/jwtValidator.rolesVersion.integration.test.js`.
294
+
295
+ ### Unchanged — the four infrastructure-health wait defaults keep their readers, and the reason is now asserted
296
+
297
+ `infrastructureHealthWaitMaxTimeMs`, `infrastructureHealthWaitCheckIntervalMs`,
298
+ `infrastructureHealthQueueWaitMaxTimeMs` and
299
+ `infrastructureHealthQueueWaitCheckIntervalMs` were put up for deletion as
300
+ declarations with no production reader
301
+ (`infra/api_services_registry/tests/unit/infrastructureHealthConfigShape.test.js:14-18`).
302
+ The four questions `change-discipline.md` § Removing requires say keep, because
303
+ question 3 fails on the evidence:
304
+
305
+ 1. **Why they exist.** So `waitForInfrastructureReady()` and
306
+ `waitForHealthCheckQueueReady()` have a platform limit and probe interval when
307
+ the caller names none — a service that boots before the infrastructure has to
308
+ wait, not die.
309
+ 2. **Which part of the concept carries them.** The boot wait on
310
+ `infrastructure:health:all`, documented in
311
+ `docs/operations/infrastructure-troubleshooting.md` § — *"up to the limit
312
+ `INFRASTRUCTURE_HEALTH_WAIT_MAX_TIME`"* — and run at gateway boot. It stands.
313
+ 3. **Why nothing reads them.** It does. Each of the four is read in this package,
314
+ in the function it exists for: `waitForInfrastructureReady.js:66-67` and
315
+ `waitForHealthCheckQueueReady.js:58-59`, via `runtimeCfg.get(key, override)`.
316
+ The INFRA measurement is true but narrower than its wording: it says the
317
+ REGISTRY does not take these four from `getInfrastructureHealthConfig()`.
318
+ 4. **Is the replacement more conceptual?** There is nothing to replace them with.
319
+ Deleting them would leave both wait functions with no limit of their own.
320
+
321
+ So nothing was deleted, and `tests/unit/healthTimingOwnership.test.js` — which
322
+ already owns "who owns which health timing" — now states it by value: each key's
323
+ default, the file that reads it, and the environment variable that overrides it,
324
+ with a control case proving the probe would notice a reader that disappeared.
325
+ Measured against itself: removing one default and one reader turns three of those
326
+ cases red.
327
+
328
+ **One rail is still two, and the other half is not ours.**
329
+ `getInfrastructureHealthConfig()` returns the same four values a second time, and
330
+ `infra/api_gateway/index.js:526-534` plus
331
+ `infra/api_delivery_dispatcher/src/services/DeliveryDispatcher.js:167-168, 227`
332
+ read them from there only to hand them straight back to
333
+ `waitForInfrastructureReady()` as `maxWait`/`checkInterval` — the same env key
334
+ resolved twice, the second resolution posing as an override of itself. The fields
335
+ stay because the gateway fail-fasts on a missing one, so removing them here would
336
+ stop a boot before those two callers stop reading: a gate lands with compliance,
337
+ never ahead of it (`automation-gates.md` §3), and the compliance is in a tree this
338
+ package must not edit. The shape is pinned so nobody removes it early — or widens
339
+ it.
340
+
341
+ ### Changed — the `Fix:` sentence is composed by the helper, not by the caller
342
+
343
+ `architecture-principles.md` §5 requires a missing key to be reported with
344
+ `Fix: set <ENV_KEY> in env-active/*.env (or pass explicit config).` The helper
345
+ did not write it: `requireEnv()` appended `description` and stopped, so the
346
+ sentence appeared exactly where somebody had typed it into that description by
347
+ hand. Measured across the platform: six call sites, all in
348
+ `infra/api_delivery_endpoint/src/config.js`, and nowhere else. Every other
349
+ required key on the platform — `RABBITMQ_URL` in the dispatcher, `REDIS_URL` in
350
+ the gateway and in `ServiceWrapper`, `SECRETS_MASTER_KEY` — failed with no fix at
351
+ all. A rule that holds only where somebody remembered it is enforced by review,
352
+ not by a mechanism (`automation-gates.md` §5); this moves it into the mechanism.
353
+
354
+ `requireEnv`, `requireNumberEnv`, `requireFloatEnv` and `requireBoolEnv` now take
355
+ a third argument, `{ file }`, and compose the whole sentence themselves:
356
+
357
+ - with the owning file: `Fix: set REDIS_URL in config/env-active/shared.env.`
358
+ - without it: §5's own generic wording, verbatim.
359
+
360
+ The file is a **bare name** (`shared.env`); the helper writes the
361
+ `config/env-active/` prefix, so the message has one shape rather than as many as
362
+ there are spellings. A value carrying a path, or not ending in `.env`, is refused
363
+ **at the call**, not the day the key goes missing — a typo must fail on the happy
364
+ path too (principle 4).
365
+
366
+ Why the file is an input and not a derivation, measured in d.399:
367
+ `api/config/shared-env.json` is the single owner of the platform's shared key set
368
+ and would answer "which file carries this key" — but only build-time tooling
369
+ reads it (`oa-sync-template shared-env` in `@onlineapps/conn-orch-validator`). It
370
+ is absent from a service's runtime; a biz repository does not carry it at all. A
371
+ library resolving it through `__dirname`/cwd would break principle 1 and invent a
372
+ second map of the same fact. So the call site names the file, the declaration
373
+ stays the authority review checks it against, and no second map exists.
374
+
375
+ The message is built as ONE template literal rather than assembled from parts,
376
+ because `tests/unit/error-message-contract.test.js` reads throws statically: a
377
+ message it cannot resolve is reported as context-less, and a probe that cannot
378
+ see is not a mechanism. Its census moved 40 → 41 with the new fail-fast on a
379
+ malformed `options.file`.
380
+
381
+ ### `INFRASTRUCTURE_HEALTH_REDIS_TTL` má jediného vlastníka — registr; druhá deklarace pryč (d.391)
382
+
383
+ `defaults.js` deklaroval `infrastructureHealthRedisTtlSeconds: 30`, `config.js` k tomu
384
+ env `INFRASTRUCTURE_HEALTH_REDIS_TTL` a `getInfrastructureHealthConfig()` to vydával jako
385
+ `redisKeyTTL`. Nikdo to nečetl a hodnota byla navíc nepravdivá.
386
+
387
+ **Čtyři otázky `change-discipline.md` § Removing:**
388
+
389
+ 1. **Proč vznikla:** hlavička `defaults.js` to říká sama — „Keep infra-related defaults
390
+ here to avoid duplication across files". Blok health timingů měl mít jednoho vlastníka
391
+ a TTL Redis klíče do něj bylo počítáno.
392
+ 2. **Která část koncepce ji nesla a stojí dál:** „jeden vlastník bloku health timingů"
393
+ platí — jen jmenuje špatného vlastníka. Redis klíč `infrastructure:health:*` píše
394
+ **jedině registr** (`infrastructureHealthTracker.js`, `setEx(key, this.redisKeyTTL, …)`)
395
+ a hodnota dává smysl jen vedle prahu, který musí přežít (`gracePeriod` 60 s); ten práh
396
+ tenhle balíček nedeklaruje a deklarovat nemá.
397
+ 3. **Proč to dnes nikdo nečte:** registr přestal spreadovat celý
398
+ `getInfrastructureHealthConfig()` a bere z něj přesně dva klíče — `queueName`
399
+ (`config.js:28`) a `publishInterval` (`config.js:296`); zbytek bloku si deklaruje sám
400
+ (`redisKeyTTL: optionalNumberEnv('INFRASTRUCTURE_HEALTH_REDIS_TTL', 90)`, `config.js:316`).
401
+ Změřeno 2026-09-14 grepem `redisKeyTTL` přes všechny `src` adresáře `infra`, `shared`
402
+ i `api_biz` (pozitivní kontrola `publishInterval` = 10 shod): mimo registr a samotnou
403
+ deklaraci nula čtenářů.
404
+ 4. **Je náhrada koncepčnější:** ano. Hodnota stojí v bloku, kde je vidět invariant
405
+ „TTL > gracePeriod", a je jenom jedna. Druhá kopie mohla s tou první už jen nesouhlasit
406
+ — a nesouhlasila: 30 versus živých 90 (3× interval sondy).
407
+
408
+ Sady: unit **260 → 265/265** (nová `tests/unit/healthTimingOwnership.test.js`, 5 testů:
409
+ klíč pryč z defaultů, pryč z výstupu getteru, env `INFRASTRUCTURE_HEALTH_REDIS_TTL=777`
410
+ výstup nezmění, a dva kontrolní — `queueName`/`publishInterval` beze změny, blok má přesně
411
+ zbylých osm klíčů). Integrace **33/33** beze změny.
412
+
413
+ ### Záložní e-mail monitoringu: sdílené spojení a opakování dočasného odmítnutí (d.318)
414
+
415
+ `sendMonitoringFailFallbackEmail` otevíral na každý mail nové TCP+TLS+AUTH
416
+ spojení. Změřeno INFRA-monitoringem 2026-09-11: sedm epizod `service_down`
417
+ v jedné sekundě, relay odpověděl `421 4.7.0 … too many connections` **18×**
418
+ a **12 poplachů** skončilo nedoručeno, protože je nic neopakovalo.
419
+
420
+ - **Pool.** Transport má `pool: true` a meze `maxConnections` / `maxMessages` /
421
+ `rateDelta` / `rateLimit` jako **deklarované konfigurační klíče**
422
+ (`INFRA_REPORT_SMTP_MAX_CONNECTIONS`, `…_MAX_MESSAGES`, `…_RATE_DELTA`,
423
+ `…_RATE_LIMIT`), žádné literály v těle. Výchozí hodnoty jsou **odvozené**
424
+ z limitů relay zapsaných v konfirmaci `alert-smtp-relay` 003 (20 spojení /
425
+ 10 AUTH za 60 s): jedno sdílené spojení = jedna autentizace na celou dávku.
426
+ - **Opakování 4xx patří transportu.** Dočasné odmítnutí (4yz, RFC 5321 §4.2.1)
427
+ se opakuje zde, ohraničeně (`INFRA_REPORT_SMTP_MAX_ATTEMPTS`, výchozí 3)
428
+ a s rozestupem pod limitem relay (`INFRA_REPORT_SMTP_RETRY_DELAY`, výchozí
429
+ 6 000 ms = 60 000/10, backoff se zdvojuje); volající dostane **jeden** verdikt,
430
+ až je po opakováních. 5yz je permanentní a neopakuje se; chyba **bez** kódu
431
+ odpovědi (mrtvý socket, padlý TLS handshake) se za dočasnou nepovažuje.
432
+ Umístění plyne z `delivery-mail-channel` 001 bodu 5 („a refused send is the
433
+ mail service's provider retry") — pro kanál poplachů je „mail service" tento
434
+ modul, takže druhá smyčka u konzumenta by byla zakázané zdvojení.
435
+ - **Fail-fast** na `INFRA_REPORT_SMTP_MAX_ATTEMPTS < 1`: to není „bez
436
+ opakování", je to smyčka, která nikdy neodešle.
437
+ - Logovací řádky `Email sent` / `Email send failed` nově nesou `attempts` —
438
+ verdikt je jeden, tak kolik pokusů stál, je vidět jen tam.
439
+ - README dostalo oddíl o této funkci a v něm **návrh** písemného zdůvodnění
440
+ druhé mailové koleje (podmínka `delivery-mail-channel` 001 § Conditions →
441
+ `duplicity-justification` 001). Je označen jako PROPOSAL: platí až jej
442
+ vlastník přijme.
443
+
444
+ ### Marker změny rolí se čte plným klíčem `state:meta:person:roles_version:` (d.382)
445
+
446
+ `ROLES_VERSION_PREFIX` byl holý `person:roles_version:`, ale zapisovatel markeru —
447
+ služba `biz-meta` — ho píše skrz svou stavovou projekci, tedy pod `state:meta:`.
448
+ Čtenář a zapisovatel se tak nikdy nepotkali: `TOKEN_STALE` po změně členství
449
+ nepadalo, protože klíč, který validátor četl, nikdo nezapisoval.
450
+
451
+ - **Kontrakt je plný klíč** `state:meta:person:roles_version:<person_id>` — týž
452
+ tvar, jakým platforma čte každou jinou projekci meta (entitlementy, kredity,
453
+ secrets). Rozhodnutí vlastníka 2026-09-14, konfirmace `redis-state-prefix` 002;
454
+ zamítnuta varianta „holý klíč mimo prefix" (klíč bez vlastníka prefixu, bez
455
+ obnovy z DB, druhá kolej v konektoru kvůli jedinému klíči).
456
+ - Exportovaná konstanta `ROLES_VERSION_PREFIX` zůstává jménem i významem; mění se
457
+ jen její hodnota. Obě hlášky, které ji citují — 401 `TOKEN_PERSON_ID_MISSING`
458
+ a 503 `ROLES_VERSION_CHECK_UNAVAILABLE` — nesou plný klíč, takže čtenář hlášky
459
+ míří na místo, kde marker opravdu je. Nově to tvrdí i testy.
460
+ - Testy tvrdí **kontrakt, ne kód**: unit i integrační tier píší a očekávají klíč
461
+ jako literál, ne přes exportovanou konstantu. Fixtura odvozená z konstanty se
462
+ sveze s jakoukoli hodnotou a zůstane zelená i pro špatný prefix — přesně to tuto
463
+ vadu drželo neviditelnou. Integrační tier píše marker do skutečného Redisu.
464
+ - **Chování se do vydání a pinu v gateway + meta-readeru nemění**: dokud tyto dvě
465
+ služby nepinují novou verzi, `TOKEN_STALE` dál nepadá — stav jako dnes, ne horší.
466
+ Projekci markeru z `tenant_membership` dluží `biz-meta` (tamtéž, § Conditions).
467
+
468
+ ### `nodemailer` `^6.9.8` → `^10.0.9` — dvanáct advisories v jednom bumpu (d.317)
469
+
470
+ `npm audit --omit=dev` hlásil na `nodemailer <=9.1.0` dvanáct advisories
471
+ (`high`), mezi nimi GHSA-p6gq-j5cr-w38f (`raw` obchází
472
+ `disableFileAccess`/`disableUrlAccess` → čtení souborů a SSRF, fix až 9.0.6),
473
+ GHSA-rcmh-qjqh-p98v a GHSA-2x7j-588g-ccc2 (DoS v `addressparser`) a
474
+ GHSA-c7w3-x93f-qmm8 (SMTP command injection přes `envelope.size`). Rozsah
475
+ `^6.9.8` na žádnou opravu nedosáhl, takže je knihovna vlekla do každého stromu,
476
+ který ji pinuje (INFRA změřila 8 stromů, `api/shared/TODO.md` § Od vlákna INFRA
477
+ (2026-08-31, dávka 128) a § Od INFRA (2026-09-09, 4)).
478
+
479
+ Čtyři majory mezi 6 a 10 a co z nich se nás týká: **7.0.0** vyhodil staré SES
480
+ SDK (nepoužíváme, transport je SMTP), **8.0.0** přejmenoval chybový kód
481
+ `NoAuth` → `ENOAUTH` (nevětvíme na něj — `monitoringFallbackEmail.js` kód jen
482
+ loguje), **9.0.0** zapnul ověřování TLS certifikátu při stahování vzdáleného
483
+ obsahu a v OAuth2/proxy (ani jedno nepoužíváme), **10.0.0** zvedl minimum na
484
+ Node 20 (máme `>=24.0.0 <25`) a přepsal balíček do TypeScriptu s CJS i ESM
485
+ buildem — `require('nodemailer').createTransport` zůstává funkce (změřeno).
486
+
487
+ Chování transportu se NEMĚNÍ: `createTransport({host, port, secure,
488
+ requireTLS, auth})` a `sendMail()` jsou beze změny. Doložil to integrační test
489
+ `tests/integration/monitoringFallbackEmailStarttls.integration.test.js` proti
490
+ skutečnému SMTP dialogu — relay bez STARTTLS dostane `EHLO`, `STARTTLS`, a
491
+ konec; heslo na drát nejde, kód chyby je pořád `ETLS` a hláška `Error upgrading
492
+ connection with STARTTLS: 502 5.5.1 Command not implemented` doslova. Naměřená
493
+ verze v komentářích toho testu je proto přepsaná na 10.0.9.
494
+
495
+ `npm audit --omit=dev`: **1 high → 0 zranitelností**. Sady: unit 253/253,
496
+ integrace 29/29.
497
+
498
+ ### `getCriticalConfigWithFallbacks()` smazán — jméno bez těla i bez konzumenta (d.257d)
499
+
500
+ Funkce měla tělo `return getCriticalConfig();` a vlastní komentář přiznával
501
+ „kept for compatibility but DOES NOT provide fallbacks". Jméno tedy slibovalo
502
+ přesný opak toho, co dělalo, a `architecture-principles.md` §11 (žádné
503
+ přechodové shimy před produkcí) takovou deklaraci zakazuje. Čtyři otázky
504
+ `.claude/rules/change-discipline.md` § Removing (konfirmace
505
+ `declaration-removal` 001):
506
+
507
+ 1. **Proč vznikla.** `9133ffa8` („unified infrastructure client config with ENV
508
+ priority") ji zavedl s **opravdovými** hardcoded fallbacky pro
509
+ docker-compose — `redis://api_node_cache:6379`,
510
+ `amqp://guest:guest@api_services_queuer:5672` — a v témže commitu označil
511
+ `getCriticalConfig` za `DEPRECATED - use getCriticalConfigWithFallbacks()
512
+ instead`. Byla to tehdy doporučená cesta.
513
+ 2. **Která část koncepce ji nesla a stojí-li dál.** „Knihovna zná topologii a
514
+ dosadí ji, když env chybí." Ta část **nestojí**: `architecture-principles.md`
515
+ §2 (No Hardcoded Values), §3 (No Fallbacks) a rozhodnutí, že topologie je
516
+ fail-fast bez defaultů.
517
+ 3. **Proč ji dnes nikdo nečte.** `a0f0a04c` („rollout: enforce strict runtime
518
+ config") jí vzal tělo, takže zbylo jméno delegující na `getCriticalConfig`;
519
+ poslední konzument (mock v `monitoring-core`) padl v `e58a0afa` (d.154a).
520
+ Role se přesně obrátily — deprecated je dnes ta funkce, ne `getCriticalConfig`.
521
+ 4. **Je náhrada koncepčnější?** Ano. `getCriticalConfig` je fail-fast a jeho
522
+ hláška pojmenuje klíč i fix. Proto „smazat", ne „zapojit".
523
+
524
+ Konzumenti: **nula** v produkčním kódu celého workspace (`grep` přes
525
+ `*.js`/`*.json`/`*.md` mimo `node_modules`; jediné zbylé výskyty byly deklarace
526
+ a testy). Nález a doporučení „smazat, testy přesměrovat" na sobě mělo vlákno
527
+ INFRA-DOCS od 2026-09-02 (`api/shared/TODO.md`, dávka 103) — tahle změna ho
528
+ provádí.
529
+
530
+ Chování, na kterém záleží, zůstává pokryté: test fail-fastu při chybějící
531
+ `REDIS_URL` se v `tests/unit/defaults.test.js` přesunul na `getCriticalConfig`,
532
+ včetně doslovné hlášky.
533
+
534
+ ### Čísla z prostředí čte resolver — `requireNumberEnv` deleguje, `requireFloatEnv` přibyl (d.257)
535
+
536
+ `requireNumberEnv()` parsoval hodnotu vlastním `Number(raw)` s vlastní hláškou
537
+ `[ServiceConfig] … must be a number`, zatímco sesterský `optionalNumberEnv()`
538
+ šel přes `@onlineapps/runtime-config` `type: 'number'`. Dvě definice toho, co je
539
+ číslo, nad jedněmi a týmiž env klíči
540
+ (`.claude/rules/change-discipline.md` § One rail per concern). Nově obě jdou
541
+ přes resolver: koerci i hlášku o chybném tvaru vlastní výhradně on, duplicitní
542
+ parsování v service-common zmizelo.
543
+
544
+ Ostrost celočíselné koerce **následuje pin resolveru**, ne tuhle změnu: s dnešním
545
+ `@onlineapps/runtime-config` 1.0.3 se neceločíselná hodnota pořád ořízne
546
+ `parseInt`em, s vydáním d.253 (`type: 'number'` = jen celé číslo) bude odmítnuta.
547
+ Proto unit testy ověřují **kontrakt delegace** (jaké schéma helper resolveru
548
+ předá a že vrátí jeho hodnotu beze změny), ne koerci samu — ta patří testům
549
+ resolveru.
550
+
551
+ Přibyl `requireFloatEnv(name, description)` → `type: 'float'` pro klíče, které
552
+ jsou poměrem nebo faktorem, ne počtem. Volba mezi oběma kolejemi je vlastnost
553
+ klíče, a proto patří na volání.
554
+
555
+ ⚠️ **Kaskáda:** `TRACE_COMPLETION_RATE_THRESHOLD` (api_monitoring `index.js:211`,
556
+ hodnota `0.8`) musí přejít na `requireFloatEnv` ve stejném kaskádovém commitu
557
+ jako pin service-common — jinak monitoring buď tiše ztratí alert (dnes: `0.8` →
558
+ `0`, a watchdog nulu přijímá jako legitimní práh,
559
+ `watchers/workflowWatchdog.js:204-208`), nebo nenabootuje (po vlně). Je to jediný
560
+ desetinný klíč ze 33, které přes `requireNumberEnv` jdou — změřeno d.257 proti
561
+ `config/env-active/*.env`.
562
+
563
+ Tvar hlášky o **chybějící** proměnné se nemění:
564
+ `[ServiceConfig] Missing environment variable - <NAME> is required. <popis>`,
565
+ společný pro `requireEnv` / `requireNumberEnv` / `requireFloatEnv` /
566
+ `requireBoolEnv`.
567
+
568
+ ### `requireTLS: true` u záložního e-mailu monitoringu (d.231)
569
+
570
+ `monitoringFallbackEmail.js` stavěl nodemailer transport bez `requireTLS`, takže
571
+ proti relayi s `smtpd_tls_security_level = may` by při chybějícím STARTTLS odešlo
572
+ `AUTH PLAIN` po nešifrovaném soketu. Flag je nastaven bezpodmínečně (u implicitního
573
+ TLS na 465 nic nemění, žádná větev podle prostředí). Integrační test staví SMTP
574
+ server bez STARTTLS na loopbacku: odeslání je odmítnuto (`ETLS`) a server `AUTH`
575
+ nikdy nedostane; kontrolní klient bez požadavku STARTTLS `AUTH` odešle.
576
+
577
+ ### `redactUrl()` — připojovací URL v logu bez credentialu (d.245)
578
+
579
+ `REDIS_URL` nese heslo k Redisu (jedna kolej, rozhodnutí leada INFRA
580
+ 2026-09-10), takže každé místo, které URL logovalo doslova, byl únik do Loki.
581
+ Nový `redactUrl(url)` (`src/redactUrl.js`, exportován z indexu) vrací totéž URL
582
+ bez userinfo — `redis://:heslo@cache:6379` → `redis://cache:6379`. Hodnota,
583
+ která není URL s hostitelem, se nahradí `<unparseable-url>`; neechuje se
584
+ (`new URL('cache-host:6379')` uspěje a přečte `cache-host:` jako schéma, proto
585
+ ta kontrola).
586
+
587
+ Předěláno na něj na třech místech, která URL psala doslova:
588
+
589
+ - `src/infrastructure/waitForInfrastructureReady.js` — `Redis URL: …` při každém bootu
590
+ - `src/infrastructure/waitForHealthCheckQueueReady.js` — totéž
591
+ - `src/redisClient.js` — `url` v payloadu každého `[Redis] Error`
592
+
593
+ Doplněno v d.245b i na zbylá tři místa v `connectRedis()` — obě hlášky
594
+ `[Redis] Connected` (úspěšná cesta, každý boot) a text timeoutové chyby. Aby se
595
+ redakce odvodila jednou na klienta, vrací `createRedisClient()` nově vedle `url`
596
+ i `loggedUrl`: `url` je skutečná adresa a jde klientovi, `loggedUrl` je jediná,
597
+ která smí do logu nebo do textu chyby. Šest míst ze šesti.
598
+
599
+ ## [2.0.1] — 2026-08-29
600
+
601
+ - Changelog started with this release; the history before it is in git (`git log -- shared/service-common`).