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