@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/CHANGELOG.md +540 -0
- package/README.md +282 -22
- package/package.json +10 -7
- package/src/config.js +13 -1
- package/src/defaults.js +28 -1
- package/src/index.js +12 -27
- package/src/infrastructure/prefixedLogger.js +49 -0
- package/src/infrastructure/waitForHealthCheckQueueReady.js +47 -44
- package/src/infrastructure/waitForInfrastructureReady.js +88 -60
- package/src/jwt/createJwtValidator.js +159 -29
- package/src/jwt/verifyAccessToken.js +30 -11
- package/src/redactUrl.js +58 -0
- package/src/redisClient.js +175 -37
- package/src/registryReader.js +26 -42
- package/src/reporting/monitoringFallbackEmail.js +163 -28
- package/src/runtime-config.js +137 -29
- package/src/errors/BusinessError.js +0 -118
- package/src/errors/errorMiddleware.js +0 -112
- package/src/errors/index.js +0 -29
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
|
-
|
|
22
|
-
|
|
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
|
|
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
|
|
58
|
-
- `checkInterval` (number): Check interval in ms
|
|
59
|
-
- `logger` (Object
|
|
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": "
|
|
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": "
|
|
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/
|
|
23
|
+
"@onlineapps/logger-contract": "1.1.0",
|
|
24
|
+
"@onlineapps/runtime-config": "1.1.0",
|
|
22
25
|
"jsonwebtoken": "^9.0.3",
|
|
23
|
-
"nodemailer": "^
|
|
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": ">=
|
|
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
|
-
|
|
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
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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 };
|