@onlineapps/service-common 2.0.1 → 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/README.md CHANGED
@@ -1,3 +1,15 @@
1
+ > Status: current
2
+ > Owns: the utilities infrastructure and business services share — JWT verification and tenant context, the Redis client, env and config readers, redaction, and the scope rules
3
+
4
+ <!-- BEGIN GENERATED: library-uniform — regenerate: npx oa-sync-template readme-uniform --all -->
5
+ Uniform: [library/runtime](../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
+ - `runtime`: L-RUNTIME-CLIENT
11
+ <!-- END GENERATED: library-uniform -->
12
+
1
13
  # @onlineapps/service-common
2
14
 
3
15
  Common utilities for both infrastructure services and business services in OA Drive.
@@ -18,34 +30,24 @@ npm install @onlineapps/service-common
18
30
 
19
31
  Wait for all infrastructure services to be ready before creating queues:
20
32
 
21
- **Infrastructure Services:**
22
- ```javascript
23
- const { waitForInfrastructureReady } = require('@onlineapps/service-common');
33
+ The same call for infrastructure and business services — the timings are not a
34
+ per-caller decision, they come from the environment (see **API** below):
24
35
 
25
- await waitForInfrastructureReady({
26
- redisUrl: 'redis://api_node_cache:6379',
27
- maxWait: 300000, // 5 minutes
28
- checkInterval: 5000, // 5 seconds
29
- logger: logger
30
- });
31
-
32
- // Now safe to create infrastructure queues
33
- ```
34
-
35
- **Business Services:**
36
36
  ```javascript
37
37
  const { waitForInfrastructureReady } = require('@onlineapps/service-common');
38
38
 
39
39
  await waitForInfrastructureReady({
40
40
  redisUrl: process.env.REDIS_URL,
41
- maxWait: 60000, // 1 minute
42
- checkInterval: 5000, // 5 seconds
43
41
  logger: logger
44
42
  });
45
43
 
46
- // Now safe to create business queues
44
+ // Now safe to create queues
47
45
  ```
48
46
 
47
+ Pass `maxWait` / `checkInterval` only to override the environment for one call;
48
+ hard-coding them in a service is how the deployed timings and the documented
49
+ ones drift apart.
50
+
49
51
  ## API
50
52
 
51
53
  ### `waitForInfrastructureReady(options)`
@@ -54,19 +56,133 @@ Waits for all infrastructure services to be reported as healthy by Registry.
54
56
 
55
57
  **Options:**
56
58
  - `redisUrl` (string): Redis URL
57
- - `maxWait` (number): Maximum wait time in ms (default: 300000 = 5 minutes)
58
- - `checkInterval` (number): Check interval in ms (default: 5000 = 5 seconds)
59
- - `logger` (Object|Function): **Required** logger instance (or function). No implicit console fallback.
59
+ - `maxWait` (number): Maximum wait time in ms optional override of `INFRASTRUCTURE_HEALTH_WAIT_MAX_TIME`
60
+ - `checkInterval` (number): Check interval in ms optional override of `INFRASTRUCTURE_HEALTH_WAIT_CHECK_INTERVAL`
61
+ - `logger` (Object): **Required** logger implementing all four contract methods
62
+ `info`, `warn`, `error`, `debug` (`@onlineapps/logger-contract`). Validated up front,
63
+ no console fallback, no shape sniffing. See **The logger contract** below.
60
64
 
61
65
  **Returns:** `Promise<boolean>` - True if all infrastructure services are ready
62
66
 
63
67
  **Throws:** `Error` - If timeout is reached
64
68
 
69
+ **Where the timings come from** (`src/config.js`, resolved by
70
+ `@onlineapps/runtime-config`): explicit option → environment variable →
71
+ module-owned default. The default values themselves live in exactly one place,
72
+ `src/defaults.js` (`infrastructureHealthWaitMaxTimeMs`,
73
+ `infrastructureHealthWaitCheckIntervalMs`) — read them there rather than from a
74
+ number copied into this file, which is how this section previously came to
75
+ advertise timings the code had not used for months.
76
+
65
77
  **Environment Variables:**
66
78
  - `REDIS_URL` - Redis connection URL
67
79
  - `INFRASTRUCTURE_HEALTH_WAIT_MAX_TIME` - Maximum wait time in ms
68
80
  - `INFRASTRUCTURE_HEALTH_WAIT_CHECK_INTERVAL` - Check interval in ms
69
81
 
82
+ ### `getInfrastructureHealthConfig()` — what this package owns, and what it does not
83
+
84
+ The function resolves the eight infrastructure-health timings this package owns:
85
+ `queueName`, `publishInterval`, `waitMaxTime`, `waitCheckInterval`,
86
+ `healthCheckTimeout`, `cleanupInterval`, `queueWaitMaxTime`,
87
+ `queueWaitCheckInterval`.
88
+
89
+ **`redisKeyTTL` is not among them, on purpose.** The TTL of an
90
+ infrastructure-health key in Redis is the registry's value: the registry is the
91
+ only code that writes that key, and the value only makes sense beside the
92
+ threshold it has to outlive (`gracePeriod`), which this package does not declare.
93
+ Until d.391 the block carried a second declaration — `INFRASTRUCTURE_HEALTH_REDIS_TTL`
94
+ with a default of 30 s — that no code read and that contradicted the 90 s the
95
+ registry actually runs on. One fact, one owner
96
+ (`.claude/rules/change-discipline.md` § One rail per concern); the owner is
97
+ `infra/api_services_registry/src/config/config.js`.
98
+
99
+ ### The logger contract
100
+
101
+ Every entry point here that logs demands the same thing, stated once in
102
+ `@onlineapps/logger-contract`: an object with `info`, `warn`, `error` and `debug`,
103
+ validated at the entry point (architecture-principles.md §3, §4). There is no
104
+ console fallback, and no sniffing of the logger's shape.
105
+
106
+ **3.0.0 is where that became true of the two infrastructure waits.** Until then
107
+ they inspected the object they were given — winston's `log({ message, level })`,
108
+ then `info`, then the logger as a bare function — and picked a rail. The cascade
109
+ was a fallback chain, its `function` rail had no caller in the workspace, and the
110
+ order alone decided the outcome: a caller passing `{ info, log }` had every
111
+ message rendered as `[object Object]`, because the winston rail was tried first
112
+ and its `log` was not winston's.
113
+
114
+ What a caller has to change:
115
+
116
+ | Caller | Was | Now |
117
+ |---|---|---|
118
+ | `@onlineapps/service-wrapper` `_waitForInfrastructureGate` | `{ info, log }` | four-method delegating logger — done |
119
+ | `api_gateway` `index.js` | `{ log, info }` wrapper | pass the service logger itself (`createInfrastructureLogger` already implements all four) |
120
+ | `api_delivery_dispatcher` `DeliveryDispatcher.js` | `{ log, info }` wrapper | same |
121
+ | `api_monitoring` consumer | `console` | unchanged — `console` implements all four |
122
+
123
+ The two wrappers exist only to prefix messages, and both waits already prefix
124
+ their own; the prefix now rides on the logger (`src/infrastructure/prefixedLogger.js`),
125
+ so a message written by a neighbour these waits hand the logger to — `connectRedis` —
126
+ carries the same mark without knowing about it.
127
+
128
+ ### JWT validation — `createJwtValidator(options)`
129
+
130
+ Express middleware. On success it sets `req.auth = { person_uuid, person_id,
131
+ email, tenants }` and calls `next()`; every refusal is a JSON body with a `code`.
132
+
133
+ | Option | Required | What it is |
134
+ |---|---|---|
135
+ | `logger` | yes | logger with `warn()` and `error()` |
136
+ | `secret` | yes | the HMAC-SHA256 signing secret, resolved by the caller at boot |
137
+ | `readRolesVersion` | yes | `(personId) => Promise<number\|null>` — the roles-version marker in epoch milliseconds, or `null` when none exists |
138
+ | `excludePaths` | no | paths that pass through without a token |
139
+
140
+ All three required options are validated at CONSTRUCTION: an instance that could
141
+ not verify a token, or could not run the roles-version check, never exists.
142
+
143
+ #### The roles-version reader
144
+
145
+ The validator does not know Redis. It used to take a `redisClient` and call
146
+ `client.isOpen` / `client.get()` — the node-redis v4 API, which meant a service
147
+ whose client is `ioredis` could not be given the check at all. The reader is a
148
+ function now, so every client shape can implement it:
149
+
150
+ ```javascript
151
+ const { createJwtValidator, ROLES_VERSION_PREFIX } = require('@onlineapps/service-common');
152
+
153
+ const readRolesVersion = async (personId) => {
154
+ const raw = await redis.get(`${ROLES_VERSION_PREFIX}${personId}`);
155
+ return raw === null ? null : Number(raw);
156
+ };
157
+
158
+ app.use(createJwtValidator({ logger, secret, readRolesVersion, excludePaths: [] }));
159
+ ```
160
+
161
+ The key is the FULL, prefixed one — `state:meta:person:roles_version:<person_id>`,
162
+ which is what `ROLES_VERSION_PREFIX` carries. The marker is a projection of the
163
+ meta service state, written by biz-meta through a connector that adds the
164
+ `state:meta:` prefix, so a reader asking for the bare `person:roles_version:<id>`
165
+ gets `null` for every person and the validator answers `TOKEN_STALE` never (owner
166
+ decision 2026-09-14, `docs/governance/confirmations/redis-state-prefix.md` 002).
167
+
168
+ The check is **fail-closed**: if the reader rejects, or answers with anything but
169
+ a finite number or `null`, the request is refused with `503`
170
+ `ROLES_VERSION_CHECK_UNAVAILABLE` — the client did nothing wrong, the platform
171
+ could not verify (`docs/governance/confirmations/jwt-stale-check-fail-closed.md`
172
+ 001). A token with no `person_id` claim is refused earlier, with `401`
173
+ `TOKEN_PERSON_ID_MISSING`, before the reader is consulted (002).
174
+
175
+ #### Units — `iat` counts seconds, the marker milliseconds
176
+
177
+ `iat` is a whole number of seconds rounded down (RFC 7519 §4.1.6); the marker is
178
+ epoch milliseconds (`docs/standards/redis-key-contract.md`). The comparison
179
+ happens in the coarser unit: the marker is floored to whole seconds, so a token
180
+ issued in the SAME second as the role change is current. Compared in
181
+ milliseconds it was not — `addMembership` writes the marker and the new token is
182
+ signed milliseconds later, inside that same second, so the token that already
183
+ carried the new roles was answered `TOKEN_STALE`, and so was the one the refresh
184
+ produced.
185
+
70
186
  ### Scoped registry — `isVisible` / `sqlVisible` and friends
71
187
 
72
188
  The single home of the registry visibility rule: *a registry row is visible to a
@@ -123,7 +239,7 @@ const { createRegistryReader } = require('@onlineapps/service-common');
123
239
  const reader = createRegistryReader({
124
240
  keyPrefix: requireEnv('REDIS_REGISTRY_KEY_PREFIX'), // REQUIRED — never assumed
125
241
  redisUrl: requireEnv('REDIS_URL'), // or: client: <connected node-redis v4 client>
126
- logger // REQUIRED — no console fallback
242
+ logger // REQUIRED — info/warn/error/debug, no console fallback
127
243
  });
128
244
  await reader.connect();
129
245
 
@@ -132,7 +248,7 @@ const op = await reader.getOperation('biz-invoicing', 'create-invoice');
132
248
 
133
249
  | Export | Purpose |
134
250
  |---|---|
135
- | `connect()` | opens the owned connection (hard `connectTimeoutMs` ceiling), or validates an injected client |
251
+ | `connect()` | opens the owned connection through `connectRedis` (hard `connectTimeoutMs` ceiling — one rail: `src/redisClient.js`), or validates an injected client |
136
252
  | `getServiceSpec(name)` | full spec, or `null` when the service is not registered |
137
253
  | `getOperation(name, key)` | one operation record, or `null` |
138
254
  | `listSummary()` | parsed `<keyPrefix>services` hash |
@@ -156,6 +272,150 @@ closes the client) or `client` (the caller owns it; `close()` never quits it).
156
272
  `keyPrefix` and `logger` are mandatory and `ttlMs` / `negativeTtlMs` /
157
273
  `connectTimeoutMs` are validated: an invalid value throws instead of being
158
274
  silently replaced by the module default (`src/defaults.js` owns the defaults).
275
+ `logger` is the platform contract — `info`, `warn`, `error`, `debug`
276
+ (`@onlineapps/logger-contract`) — checked at construction, because the Redis
277
+ client the reader owns logs through the same object. A failed `connect()` on the
278
+ owned path raises the `connectRedis` contract message, which names the endpoint
279
+ with the userinfo stripped and keeps the original error as `cause`.
280
+
281
+ ### Env readers — `requireNumberEnv` vs `requireFloatEnv`
282
+
283
+ A number from the environment is read by exactly ONE rail:
284
+ `@onlineapps/runtime-config`. The helpers here only hand it a schema and return
285
+ what it resolved — neither the coercion nor the message for a malformed value is
286
+ owned by service-common.
287
+
288
+ | Helper | Resolver type | For |
289
+ |---|---|---|
290
+ | `requireNumberEnv(name, description, options)` | `number` | whole numbers — ports, counts, budgets, ms/second/minute intervals |
291
+ | `requireFloatEnv(name, description, options)` | `float` | ratios and factors, e.g. a completion threshold in `0.0-1.0` |
292
+ | `requireBoolEnv(name, description, options)` | — | the literal `true` / `false` |
293
+ | `requireEnv(name, description, options)` | — | a string |
294
+
295
+ ### `options.file` — who writes the `Fix:` sentence
296
+
297
+ `architecture-principles.md` §5 requires a missing key to be reported with
298
+ `Fix: set <ENV_KEY> in env-active/*.env`. Since d.399 **the helper composes that
299
+ sentence**, for every caller and in one shape. Before it, the helper appended
300
+ only `description`, so the sentence existed exactly where a caller had typed it
301
+ into that description by hand — six call sites in
302
+ `infra/api_delivery_endpoint/src/config.js`, and nowhere else on the platform.
303
+
304
+ ```javascript
305
+ requireEnv('REDIS_URL', 'Redis connection URL', { file: 'shared.env' });
306
+ // [ServiceConfig] Missing environment variable - REDIS_URL is required.
307
+ // Redis connection URL Fix: set REDIS_URL in config/env-active/shared.env.
308
+
309
+ requireEnv('REDIS_URL', 'Redis connection URL');
310
+ // … Fix: set REDIS_URL in env-active/*.env (or pass explicit config).
311
+ ```
312
+
313
+ `file` is a **bare file name** — the helper writes the `config/env-active/`
314
+ prefix, and a value carrying a path or a different suffix is refused at the call
315
+ itself, not the day the key goes missing. It is an INPUT and never a derivation:
316
+ `api/config/shared-env.json` owns the shared key set, but only build-time tooling
317
+ (`oa-sync-template shared-env`) reads it — the file is absent from a service's
318
+ runtime, and a library hunting for it through `__dirname`/cwd would break
319
+ principle 1. So the call site names the file and review checks it against the
320
+ declaration; without one, the sentence is §5's own generic wording, which is
321
+ correct rather than missing.
322
+
323
+ Choosing between `number` and `float` is a **property of the key**, not of the
324
+ helper, so it belongs at the call site: a fractional value read as `number`
325
+ loses its fraction today, and stops the boot once the whole-number resolver
326
+ (d.253) is pinned.
327
+
328
+ ⚠️ Open cascade: `TRACE_COMPLETION_RATE_THRESHOLD` (api_monitoring
329
+ `index.js:211`, value `0.8`) must move to `requireFloatEnv` in the same cascade
330
+ commit as the service-common pin — otherwise monitoring either loses the alert
331
+ silently (today) or fails to boot (after the wave).
332
+
333
+ A missing variable is reported by all four in the same shape:
334
+ `[ServiceConfig] Missing environment variable - <NAME> is required. <description>`.
335
+
336
+ ### Monitoring fallback e-mail — `sendMonitoringFailFallbackEmail(subject, text, html, logger)`
337
+
338
+ The last reporting channel: when the platform's own monitoring path cannot carry
339
+ an alert, this sends it by SMTP. Every outcome is swallowed into the boolean
340
+ return, so the injected `logger` is the only record of what happened — there is
341
+ no console fallback and no throw for a refused send.
342
+
343
+ **One shared connection.** The transport is pooled (`pool: true`). A burst of
344
+ alerts used to cost one TCP+TLS+AUTH per mail, which is what broke on
345
+ 2026-09-11: seven `service_down` episodes in one second, `421 4.7.0 … too many
346
+ connections` 18×, 12 alerts never delivered. The bounds are declared config, not
347
+ literals, and their owner defaults are derived from the relay limits recorded in
348
+ [`alert-smtp-relay` 003](../../docs/governance/confirmations/alert-smtp-relay.md)
349
+ (20 connections / 10 AUTH per 60 s):
350
+
351
+ | Key | Env | Default | Why that value |
352
+ |---|---|---|---|
353
+ | `infraReportSmtpMaxConnections` | `INFRA_REPORT_SMTP_MAX_CONNECTIONS` | `1` | one connection per burst = one AUTH, not one per mail |
354
+ | `infraReportSmtpMaxMessages` | `INFRA_REPORT_SMTP_MAX_MESSAGES` | `100` | messages carried before nodemailer recycles the connection |
355
+ | `infraReportSmtpRateDeltaMs` | `INFRA_REPORT_SMTP_RATE_DELTA` | `60000` | the window the relay's limits are counted in |
356
+ | `infraReportSmtpRateLimit` | `INFRA_REPORT_SMTP_RATE_LIMIT` | `10` | the AUTH limit, so even one connection per message stays inside it |
357
+ | `infraReportSmtpMaxAttempts` | `INFRA_REPORT_SMTP_MAX_ATTEMPTS` | `3` | bounded retry; below `1` is a fail-fast error, not "no retries" |
358
+ | `infraReportSmtpRetryDelayMs` | `INFRA_REPORT_SMTP_RETRY_DELAY` | `6000` | 60 000 / 10 = one AUTH every 6 s, the tightest limit on record; the backoff doubles from there |
359
+
360
+ **A temporary refusal is re-tried here, by the transport, and the caller is told
361
+ once.** A 4yz reply (RFC 5321 §4.2.1 — `421`, `450`, …) is the relay saying
362
+ "later": the send is repeated up to `maxAttempts`, spaced by the doubling
363
+ backoff, and the function returns a single verdict when that is over. A 5yz
364
+ reply is permanent and is never repeated. An error carrying **no** reply code (a
365
+ dead socket, a failed TLS handshake) is not guessed to be temporary — a retry
366
+ would spend the relay's rate budget on something the relay never said.
367
+
368
+ That placement is not a local choice: `delivery-mail-channel` 001 point 5 says
369
+ "retry is layered, not doubled … a refused send is the mail service's provider
370
+ retry". For the alert channel this module IS the mail service, so a retry loop in
371
+ the monitoring consumer on top of this one would be exactly the doubling that
372
+ entry forbids. What the consumer records (`notification_sent`) is this verdict.
373
+
374
+ The transport also sets `requireTLS: true` unconditionally: the relay offers
375
+ STARTTLS without insisting (`smtpd_tls_security_level = may`), so a client that
376
+ does not insist either hands `AUTH PLAIN` to a downgraded connection.
377
+
378
+ #### Why this is a second mail rail — accepted justification
379
+
380
+ > **Status: ACCEPTED** by the owner on 2026-09-14
381
+ > (`api/docs/governance/confirmations/duplicity-justification.md` 002). `delivery-mail-channel`
382
+ > 001 § Conditions requires this duplicate to carry "its own written justification under
383
+ > `duplicity-justification` 001"; this section is that justification. It holds for exactly
384
+ > the shape described below — platform alerts to the operator, one SMTP transport — and a
385
+ > change that widens the rail leaves it and needs a new entry.
386
+
387
+ The platform has one mail rail by decision: a workflow result that travels by
388
+ mail is handed to the mail service over its workflow queue
389
+ (`delivery-mail-channel` 001, model A). This function is a second one, and
390
+ `change-discipline.md` § One rail per concern makes that a defect by default.
391
+
392
+ The argument that it is nevertheless the right shape:
393
+
394
+ 1. **The two rails do not serve one concern.** The mail service delivers a
395
+ *tenant's* result — with the tenant's transport, entitlement check, outbound
396
+ record, bounce and complaint lifecycle. This function delivers *the
397
+ platform's own alert about itself*, to the operator, with no tenant, no
398
+ entitlement and no lifecycle. `markAlertNotified` is the whole record
399
+ (`monitoring-pg-volume` 003, `biz-readiness-alerting` 002).
400
+ 2. **It exists for the case where the first rail cannot run.** An alert is
401
+ raised precisely when the infrastructure the first rail depends on — the biz
402
+ layer, MQ, the queue the dispatcher would publish to — is the thing that is
403
+ broken. A channel whose availability is conditional on the subject of its own
404
+ message is not a channel. Routing alerts through the mail service would make
405
+ the report of an outage the first casualty of that outage.
406
+ 3. **The dependency direction forbids the reuse.** `service-common` is a shared
407
+ library used by infrastructure services (layer order,
408
+ `architecture-principles.md` §7). Reaching the mail service from here would
409
+ put a biz-service dependency underneath the infrastructure that monitors it.
410
+ 4. **It is not a copy of the mail service.** There is no queue, no template
411
+ engine, no attachment resolution, no provider feedback — one SMTP transport
412
+ and one `sendMail`. It duplicates the *act* of sending mail, not the
413
+ mechanism the other rail owns.
414
+ 5. **The cost of the alternative is measured, not theoretical.** The rejected
415
+ alternative (alerts through the mail service) was live on 2026-09-11 in the
416
+ form of a single relay dependency, and 12 alerts were lost to one burst; the
417
+ fix documented above is what keeps this rail inside the relay's limits.
418
+
159
419
 
160
420
  ## Architecture
161
421
 
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "@onlineapps/service-common",
3
- "version": "2.0.1",
3
+ "version": "3.0.0",
4
4
  "description": "Common utilities for both infrastructure services and business services (JWT auth, Redis client, business errors, runtime config)",
5
+ "oa": {
6
+ "category": "runtime"
7
+ },
5
8
  "main": "src/index.js",
6
9
  "scripts": {
7
- "test": "jest",
10
+ "test": "npm run test:unit && npm run test:integration",
8
11
  "test:unit": "jest tests/unit",
9
- "test:integration": "jest --config=jest.integration.config.js",
10
- "test:all": "npm run test && npm run test:integration"
12
+ "test:integration": "jest --config=jest.integration.config.js"
11
13
  },
12
14
  "keywords": [
13
15
  "microservices",
@@ -18,15 +20,16 @@
18
20
  "author": "OA Drive Team",
19
21
  "license": "MIT",
20
22
  "dependencies": {
21
- "@onlineapps/runtime-config": "1.0.3",
23
+ "@onlineapps/logger-contract": "1.1.0",
24
+ "@onlineapps/runtime-config": "1.1.0",
22
25
  "jsonwebtoken": "^9.0.3",
23
- "nodemailer": "^6.9.8",
26
+ "nodemailer": "^10.0.9",
24
27
  "redis": "^4.6.0"
25
28
  },
26
29
  "devDependencies": {
27
30
  "jest": "^29.7.0"
28
31
  },
29
32
  "engines": {
30
- "node": ">=14.0.0"
33
+ "node": ">=24.0.0 <25"
31
34
  }
32
35
  }
package/src/config.js CHANGED
@@ -33,7 +33,6 @@ const runtimeCfg = createRuntimeConfig({
33
33
  infrastructureHealthWaitMaxTimeMs: { env: 'INFRASTRUCTURE_HEALTH_WAIT_MAX_TIME', defaultKey: 'infrastructureHealthWaitMaxTimeMs', type: 'number' },
34
34
  infrastructureHealthWaitCheckIntervalMs: { env: 'INFRASTRUCTURE_HEALTH_WAIT_CHECK_INTERVAL', defaultKey: 'infrastructureHealthWaitCheckIntervalMs', type: 'number' },
35
35
  infrastructureHealthTimeoutMs: { env: 'INFRASTRUCTURE_HEALTH_TIMEOUT', defaultKey: 'infrastructureHealthTimeoutMs', type: 'number' },
36
- infrastructureHealthRedisTtlSeconds: { env: 'INFRASTRUCTURE_HEALTH_REDIS_TTL', defaultKey: 'infrastructureHealthRedisTtlSeconds', type: 'number' },
37
36
  infrastructureHealthCleanupIntervalMs: { env: 'INFRASTRUCTURE_HEALTH_CLEANUP_INTERVAL', defaultKey: 'infrastructureHealthCleanupIntervalMs', type: 'number' },
38
37
  infrastructureHealthQueueWaitMaxTimeMs: { env: 'INFRASTRUCTURE_HEALTH_QUEUE_WAIT_MAX_TIME', defaultKey: 'infrastructureHealthQueueWaitMaxTimeMs', type: 'number' },
39
38
  infrastructureHealthQueueWaitCheckIntervalMs: { env: 'INFRASTRUCTURE_HEALTH_QUEUE_WAIT_CHECK_INTERVAL', defaultKey: 'infrastructureHealthQueueWaitCheckIntervalMs', type: 'number' },
@@ -46,6 +45,19 @@ const runtimeCfg = createRuntimeConfig({
46
45
  infraReportSmtpPass: { env: 'INFRA_REPORT_SMTP_PASS' },
47
46
  infraReportFrom: { env: 'INFRA_REPORT_FROM' },
48
47
  infraReportTo: { env: 'INFRA_REPORT_TO' },
48
+
49
+ // How the fallback transport treats the relay: one shared connection and a
50
+ // bounded retry of a TEMPORARY refusal. Unlike the keys above these DO have
51
+ // owner defaults — they describe how to talk to whatever relay is
52
+ // configured, not whether the feature is configured at all, and the
53
+ // defaults are derived from the relay limits recorded in confirmation
54
+ // `alert-smtp-relay` 003 (see ./defaults.js for the derivation).
55
+ infraReportSmtpMaxConnections: { env: 'INFRA_REPORT_SMTP_MAX_CONNECTIONS', defaultKey: 'infraReportSmtpMaxConnections', type: 'number' },
56
+ infraReportSmtpMaxMessages: { env: 'INFRA_REPORT_SMTP_MAX_MESSAGES', defaultKey: 'infraReportSmtpMaxMessages', type: 'number' },
57
+ infraReportSmtpRateDeltaMs: { env: 'INFRA_REPORT_SMTP_RATE_DELTA', defaultKey: 'infraReportSmtpRateDeltaMs', type: 'number' },
58
+ infraReportSmtpRateLimit: { env: 'INFRA_REPORT_SMTP_RATE_LIMIT', defaultKey: 'infraReportSmtpRateLimit', type: 'number' },
59
+ infraReportSmtpMaxAttempts: { env: 'INFRA_REPORT_SMTP_MAX_ATTEMPTS', defaultKey: 'infraReportSmtpMaxAttempts', type: 'number' },
60
+ infraReportSmtpRetryDelayMs: { env: 'INFRA_REPORT_SMTP_RETRY_DELAY', defaultKey: 'infraReportSmtpRetryDelayMs', type: 'number' },
49
61
  }
50
62
  });
51
63
 
package/src/defaults.js CHANGED
@@ -15,12 +15,39 @@ module.exports = {
15
15
  infrastructureHealthWaitMaxTimeMs: 60000,
16
16
  infrastructureHealthWaitCheckIntervalMs: 2000,
17
17
  infrastructureHealthTimeoutMs: 15000,
18
- infrastructureHealthRedisTtlSeconds: 30,
18
+ // NOTE: no `infrastructureHealthRedisTtlSeconds` here. The Redis TTL of an
19
+ // infrastructure-health key belongs to the registry, which is the only code
20
+ // that writes that key, and it declares the value beside the thresholds the
21
+ // value has to outlive (`gracePeriod`) — see
22
+ // infra/api_services_registry/src/config/config.js. This package declared a
23
+ // second one (30 s) that nothing read and that contradicted the live 90 s.
19
24
  infrastructureHealthCleanupIntervalMs: 10000,
20
25
 
21
26
  infrastructureHealthQueueWaitMaxTimeMs: 60000,
22
27
  infrastructureHealthQueueWaitCheckIntervalMs: 2000,
23
28
 
29
+ // Monitoring fallback e-mail transport (see ./reporting/monitoringFallbackEmail.js).
30
+ //
31
+ // The values are DERIVED from the relay's documented limits, they are not
32
+ // taste: confirmation `alert-smtp-relay` 003 § Conditions records
33
+ // `smtpd_client_connection_rate_limit=20` and `smtpd_client_auth_rate_limit=10`
34
+ // per 60 s. An alert burst that opens one connection per mail walks straight
35
+ // into both (measured 2026-09-11: 18× `421 4.7.0 … too many connections`,
36
+ // 12 alerts undelivered).
37
+ //
38
+ // - one connection shared by the whole burst => one AUTH, not one per mail;
39
+ // - 100 messages on that connection before nodemailer recycles it;
40
+ // - 10 messages per 60 s, i.e. the AUTH limit, so even the worst case of one
41
+ // connection per message stays inside what the relay allows;
42
+ // - three attempts spaced 6 s and 12 s: 60 000 / 10 is one AUTH every 6 s,
43
+ // which is the tightest limit on record.
44
+ infraReportSmtpMaxConnections: 1,
45
+ infraReportSmtpMaxMessages: 100,
46
+ infraReportSmtpRateDeltaMs: 60000,
47
+ infraReportSmtpRateLimit: 10,
48
+ infraReportSmtpMaxAttempts: 3,
49
+ infraReportSmtpRetryDelayMs: 6000,
50
+
24
51
  // registryReader in-memory cache (see ./registryReader.js).
25
52
  // Positive entries live 5 minutes; a "service not registered" answer is
26
53
  // remembered for 30 s only, so a fresh registration becomes visible quickly.
package/src/index.js CHANGED
@@ -21,26 +21,13 @@ const { createRegistryReader } = require('./registryReader');
21
21
  const {
22
22
  requireEnv,
23
23
  requireNumberEnv,
24
+ requireFloatEnv,
24
25
  requireBoolEnv,
25
26
  optionalEnv,
26
27
  optionalNumberEnv,
27
28
  getCriticalConfig,
28
- getCriticalConfigWithFallbacks,
29
29
  getInfrastructureHealthConfig
30
30
  } = require('./runtime-config');
31
- const {
32
- BusinessError,
33
- NotFoundError,
34
- ValidationError,
35
- ConflictError,
36
- BusinessRuleError,
37
- AuthorizationError,
38
- ServiceUnavailableError,
39
- isBusinessError,
40
- ERROR_TYPES,
41
- businessErrorHandler,
42
- notFoundHandler
43
- } = require('./errors');
44
31
  const {
45
32
  verifyAccessToken,
46
33
  createJwtValidator,
@@ -52,6 +39,7 @@ const {
52
39
  sensitiveFieldsForOperation,
53
40
  redactSensitiveDeep
54
41
  } = require('./redactSensitive');
42
+ const { redactUrl } = require('./redactUrl');
55
43
  const {
56
44
  isVisible,
57
45
  filterVisible,
@@ -82,28 +70,21 @@ module.exports = {
82
70
  // Configuration helpers (NO FALLBACKS for critical infrastructure)
83
71
  requireEnv,
84
72
  requireNumberEnv,
73
+ requireFloatEnv,
85
74
  requireBoolEnv,
86
75
  optionalEnv,
87
76
  optionalNumberEnv,
88
77
  getCriticalConfig,
89
- getCriticalConfigWithFallbacks,
90
78
  getInfrastructureHealthConfig,
91
79
 
92
80
  // Reporting utilities
93
81
  sendMonitoringFailFallbackEmail,
94
82
 
95
- // Business error classes and middleware
96
- BusinessError,
97
- NotFoundError,
98
- ValidationError,
99
- ConflictError,
100
- BusinessRuleError,
101
- AuthorizationError,
102
- ServiceUnavailableError,
103
- isBusinessError,
104
- ERROR_TYPES,
105
- businessErrorHandler,
106
- notFoundHandler,
83
+ // The BusinessError hierarchy and the Express error middleware used to sit here.
84
+ // They left in the 3.0.0 wave: the classes live in @onlineapps/service-wrapper
85
+ // (the winning class), and the middleware had no mount site anywhere after F6
86
+ // removed Express from biz services.
87
+ // See: tests/unit/retiredErrorExports.test.js
107
88
 
108
89
  // JWT validation utilities (shared across services that accept client HTTP/WS requests)
109
90
  // See: docs/standards/JWT_AUTH.md
@@ -118,6 +99,10 @@ module.exports = {
118
99
  sensitiveFieldsForOperation,
119
100
  redactSensitiveDeep,
120
101
 
102
+ // Credential redaction for connection URLs written to a log
103
+ // (REDIS_URL carries the Redis password — one rail, INFRA lead 2026-09-10)
104
+ redactUrl,
105
+
121
106
  // Scoped-registry visibility rule (system ∪ own workspace)
122
107
  // See: docs/biz/30-operations/scoped-registry.md
123
108
  isVisible,
@@ -0,0 +1,49 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * prefixedLogger.js
5
+ *
6
+ * Both infrastructure waits (`waitForInfrastructureReady`,
7
+ * `waitForHealthCheckQueueReady`) need the same thing: the caller's logger, with
8
+ * every line marked as coming from that wait. They used to build it twice, by
9
+ * hand, and each hand-built object was a defect of its own — two methods where
10
+ * the contract asks for four (`@onlineapps/logger-contract`), which `connectRedis`
11
+ * refused the moment it started validating them.
12
+ *
13
+ * One concern, one rail (change-discipline.md § One rail per concern).
14
+ *
15
+ * The prefix belongs on the logger rather than in every message string, so a
16
+ * message written here and a message written by a neighbour this logger is handed
17
+ * to (`connectRedis`) carry the same mark without either of them knowing it.
18
+ *
19
+ * @see api/docs/standards/ERROR_HANDLING.md
20
+ * @module @onlineapps/service-common/src/infrastructure/prefixedLogger
21
+ */
22
+
23
+ const { LOGGER_METHODS, assertLogger } = require('@onlineapps/logger-contract');
24
+
25
+ /**
26
+ * Wraps a contract logger so every message is prefixed.
27
+ *
28
+ * @param {string} context - Entry-point name for the validation message, e.g. 'waitForInfrastructureReady'
29
+ * @param {string} prefix - Marker put in front of every message, e.g. '[InfrastructureReady]'
30
+ * @param {Object} logger - Candidate logger, validated against the four-method contract
31
+ * @param {string} reason - Why this component needs a logger, in one clause
32
+ * @returns {Object} a logger implementing the same four methods
33
+ * @throws {Error} when the logger is absent or does not implement all four
34
+ */
35
+ function createPrefixedLogger(context, prefix, logger, reason) {
36
+ const target = assertLogger(context, logger, reason);
37
+
38
+ const prefixed = {};
39
+ for (const level of LOGGER_METHODS) {
40
+ // `...rest` rather than a named `meta`: a caller that logged a message alone
41
+ // must not have an `undefined` second argument invented for it on the way
42
+ // through, because the logger underneath can tell the two apart.
43
+ prefixed[level] = (message, ...rest) => target[level](`${prefix} ${message}`, ...rest);
44
+ }
45
+
46
+ return prefixed;
47
+ }
48
+
49
+ module.exports = { createPrefixedLogger };