@onlineapps/service-common 2.0.1 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +601 -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 +18 -30
- 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/redisClient.js +175 -37
- package/src/registryReader.js +26 -42
- package/src/reporting/monitoringFallbackEmail.js +163 -28
- package/src/runtime-config.js +154 -35
- package/src/errors/BusinessError.js +0 -118
- package/src/errors/errorMiddleware.js +0 -112
- package/src/errors/index.js +0 -29
- package/src/redactSensitive.js +0 -87
|
@@ -2,10 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
const nodemailer = require('nodemailer');
|
|
4
4
|
const runtimeCfg = require('../config');
|
|
5
|
+
const { assertLogger } = require('@onlineapps/logger-contract');
|
|
5
6
|
|
|
6
7
|
let transporter = null;
|
|
7
8
|
let cachedConfigSignature = null;
|
|
8
9
|
|
|
10
|
+
/**
|
|
11
|
+
* The lowest and highest SMTP reply code that means "try again later"
|
|
12
|
+
* (RFC 5321 §4.2.1: a 4yz reply is a transient negative completion, a 5yz reply
|
|
13
|
+
* is permanent). These are protocol constants, not configuration — the relay
|
|
14
|
+
* does not get to redefine what `421` means.
|
|
15
|
+
*/
|
|
16
|
+
const TRANSIENT_REPLY_MIN = 400;
|
|
17
|
+
const TRANSIENT_REPLY_MAX = 499;
|
|
18
|
+
|
|
9
19
|
function loadConfig() {
|
|
10
20
|
return {
|
|
11
21
|
host: runtimeCfg.get('infraReportSmtpHost'),
|
|
@@ -14,10 +24,36 @@ function loadConfig() {
|
|
|
14
24
|
user: runtimeCfg.get('infraReportSmtpUser'),
|
|
15
25
|
pass: runtimeCfg.get('infraReportSmtpPass'),
|
|
16
26
|
from: runtimeCfg.get('infraReportFrom'),
|
|
17
|
-
to: runtimeCfg.get('infraReportTo')
|
|
27
|
+
to: runtimeCfg.get('infraReportTo'),
|
|
28
|
+
maxConnections: runtimeCfg.get('infraReportSmtpMaxConnections'),
|
|
29
|
+
maxMessages: runtimeCfg.get('infraReportSmtpMaxMessages'),
|
|
30
|
+
rateDeltaMs: runtimeCfg.get('infraReportSmtpRateDeltaMs'),
|
|
31
|
+
rateLimit: runtimeCfg.get('infraReportSmtpRateLimit'),
|
|
32
|
+
maxAttempts: runtimeCfg.get('infraReportSmtpMaxAttempts'),
|
|
33
|
+
retryDelayMs: runtimeCfg.get('infraReportSmtpRetryDelayMs')
|
|
18
34
|
};
|
|
19
35
|
}
|
|
20
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Is this refusal the relay saying "later", or "never"?
|
|
39
|
+
*
|
|
40
|
+
* Only a real reply code answers that. An error without one — a socket that
|
|
41
|
+
* died, a TLS handshake that failed — is NOT guessed to be temporary: a retry
|
|
42
|
+
* then spends the relay's auth-rate budget on something the relay never said.
|
|
43
|
+
*
|
|
44
|
+
* @param {Error} error - The error nodemailer rejected with
|
|
45
|
+
* @returns {boolean} true when the relay replied 4yz
|
|
46
|
+
*/
|
|
47
|
+
function isTemporaryRefusal(error) {
|
|
48
|
+
return Number.isInteger(error.responseCode)
|
|
49
|
+
&& error.responseCode >= TRANSIENT_REPLY_MIN
|
|
50
|
+
&& error.responseCode <= TRANSIENT_REPLY_MAX;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function wait(delayMs) {
|
|
54
|
+
return new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
55
|
+
}
|
|
56
|
+
|
|
21
57
|
function createTransportIfNeeded(config) {
|
|
22
58
|
const signature = JSON.stringify(config);
|
|
23
59
|
if (transporter && cachedConfigSignature === signature) {
|
|
@@ -32,6 +68,34 @@ function createTransportIfNeeded(config) {
|
|
|
32
68
|
host: config.host,
|
|
33
69
|
port: config.port,
|
|
34
70
|
secure: config.secure,
|
|
71
|
+
// The relay runs `smtpd_tls_security_level = may`: it OFFERS STARTTLS and
|
|
72
|
+
// does not insist. Without this flag nodemailer does not insist either, so a
|
|
73
|
+
// relay that stops advertising STARTTLS — a downgrade, a misconfiguration,
|
|
74
|
+
// someone in the middle — gets `AUTH PLAIN <base64>` on a clear socket and
|
|
75
|
+
// the account is gone. With it, that send is refused instead.
|
|
76
|
+
//
|
|
77
|
+
// Set unconditionally. On implicit TLS (`secure: true`, port 465) the
|
|
78
|
+
// channel is already encrypted and the flag changes nothing, and there is
|
|
79
|
+
// deliberately no "only in production" branch: behaviour that depends on
|
|
80
|
+
// NODE_ENV is what `architecture-principles.md` §8 forbids, and the dev
|
|
81
|
+
// relay carries a real password too.
|
|
82
|
+
requireTLS: true,
|
|
83
|
+
// One connection shared by the whole burst, instead of one TCP+TLS+AUTH per
|
|
84
|
+
// mail. Measured 2026-09-11 (INFRA-monitoring): seven `service_down`
|
|
85
|
+
// episodes in one second opened seven connections, the relay answered
|
|
86
|
+
// `421 4.7.0 … too many connections` 18×, and 12 alerts were never
|
|
87
|
+
// delivered. The relay's own limits are 20 connections / 10 AUTH per 60 s
|
|
88
|
+
// (confirmation `alert-smtp-relay` 003 § Conditions), so the burst, not the
|
|
89
|
+
// volume, is what breaks: pooling turns N authentications into one.
|
|
90
|
+
//
|
|
91
|
+
// Every value is a declared config key with an owner default derived from
|
|
92
|
+
// those limits — see ../defaults.js. No literal here, and no
|
|
93
|
+
// environment-dependent branch (`architecture-principles.md` §8).
|
|
94
|
+
pool: true,
|
|
95
|
+
maxConnections: config.maxConnections,
|
|
96
|
+
maxMessages: config.maxMessages,
|
|
97
|
+
rateDelta: config.rateDeltaMs,
|
|
98
|
+
rateLimit: config.rateLimit,
|
|
35
99
|
auth: {
|
|
36
100
|
user: config.user,
|
|
37
101
|
pass: config.pass
|
|
@@ -41,45 +105,116 @@ function createTransportIfNeeded(config) {
|
|
|
41
105
|
return transporter;
|
|
42
106
|
}
|
|
43
107
|
|
|
44
|
-
|
|
108
|
+
/**
|
|
109
|
+
* Send the monitoring fallback email.
|
|
110
|
+
*
|
|
111
|
+
* Every outcome is swallowed into the boolean return, so the injected logger is
|
|
112
|
+
* the only record of what this channel — the last one left when monitoring is
|
|
113
|
+
* down — actually did (confirmation 001, 002, 003).
|
|
114
|
+
*
|
|
115
|
+
* @param {string} subject - Mail subject
|
|
116
|
+
* @param {string} text - Plain-text body
|
|
117
|
+
* @param {string} html - HTML body
|
|
118
|
+
* @param {Object} logger - Logger with info/warn/error/debug (required)
|
|
119
|
+
* @returns {Promise<boolean>} true when the mail was handed to the SMTP server
|
|
120
|
+
*/
|
|
121
|
+
async function sendMonitoringFailFallbackEmail(subject, text, html, logger) {
|
|
122
|
+
const log = assertLogger(
|
|
123
|
+
'sendMonitoringFailFallbackEmail',
|
|
124
|
+
logger,
|
|
125
|
+
'the outcome of the last reporting channel is not swallowed with its boolean'
|
|
126
|
+
);
|
|
127
|
+
|
|
45
128
|
const config = loadConfig();
|
|
129
|
+
|
|
130
|
+
// Fail-fast at method entry (`architecture-principles.md` §4): a bound below
|
|
131
|
+
// one is not "no retries", it is a loop that never sends. It cannot happen
|
|
132
|
+
// with the owner default, only with an explicitly configured value, so it is a
|
|
133
|
+
// deployment defect and is raised as one rather than swallowed into the
|
|
134
|
+
// boolean — the caller (`api_monitoring` alerting) records it as the reason.
|
|
135
|
+
if (!Number.isInteger(config.maxAttempts) || config.maxAttempts < 1) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
'[MonitoringFallbackEmail] Invalid retry bound - ' +
|
|
138
|
+
`INFRA_REPORT_SMTP_MAX_ATTEMPTS must be an integer >= 1, got "${config.maxAttempts}". ` +
|
|
139
|
+
'Fix: set INFRA_REPORT_SMTP_MAX_ATTEMPTS in env-active/*.env, or unset it to use the owner default.'
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
46
143
|
const mailer = createTransportIfNeeded(config);
|
|
47
144
|
|
|
48
145
|
if (!mailer) {
|
|
49
|
-
|
|
146
|
+
// Presence only — the SMTP password must never reach a log line.
|
|
147
|
+
log.warn('[MonitoringFallbackEmail] SMTP configuration missing, skipping send', {
|
|
148
|
+
hasHost: Boolean(config.host),
|
|
149
|
+
hasPort: Boolean(config.port),
|
|
150
|
+
hasUser: Boolean(config.user),
|
|
151
|
+
hasPass: Boolean(config.pass),
|
|
152
|
+
hasFrom: Boolean(config.from),
|
|
153
|
+
hasTo: Boolean(config.to)
|
|
154
|
+
});
|
|
50
155
|
return false;
|
|
51
156
|
}
|
|
52
157
|
|
|
53
158
|
const recipients = config.to.split(',').map(addr => addr.trim()).filter(Boolean);
|
|
54
159
|
if (recipients.length === 0) {
|
|
55
|
-
|
|
160
|
+
log.warn('[MonitoringFallbackEmail] No recipients configured', { envKey: 'INFRA_REPORT_TO' });
|
|
56
161
|
return false;
|
|
57
162
|
}
|
|
58
163
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
164
|
+
// A temporary refusal is re-tried HERE, by the transport, and the caller is
|
|
165
|
+
// told once — `delivery-mail-channel` 001 point 5: "retry is layered, not
|
|
166
|
+
// doubled … a refused send is the mail service's provider retry". For the
|
|
167
|
+
// alert channel this module IS the mail service, so a second retry loop in
|
|
168
|
+
// the monitoring consumer would be the doubling that entry forbids; what the
|
|
169
|
+
// consumer records (`notification_sent`) is this function's single verdict.
|
|
170
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
171
|
+
try {
|
|
172
|
+
const result = await mailer.sendMail({
|
|
173
|
+
from: config.from,
|
|
174
|
+
to: recipients,
|
|
175
|
+
subject,
|
|
176
|
+
text,
|
|
177
|
+
html
|
|
178
|
+
});
|
|
179
|
+
log.info('[MonitoringFallbackEmail] Email sent', {
|
|
180
|
+
messageId: result.messageId,
|
|
181
|
+
response: result.response,
|
|
182
|
+
accepted: result.accepted,
|
|
183
|
+
rejected: result.rejected,
|
|
184
|
+
attempts: attempt
|
|
185
|
+
});
|
|
186
|
+
return true;
|
|
187
|
+
} catch (error) {
|
|
188
|
+
const retryable = isTemporaryRefusal(error) && attempt < config.maxAttempts;
|
|
189
|
+
|
|
190
|
+
if (!retryable) {
|
|
191
|
+
log.error('[MonitoringFallbackEmail] Email send failed', {
|
|
192
|
+
reason: error.message,
|
|
193
|
+
code: error.code,
|
|
194
|
+
command: error.command,
|
|
195
|
+
response: error.response,
|
|
196
|
+
responseCode: error.responseCode,
|
|
197
|
+
attempts: attempt
|
|
198
|
+
});
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Doubling backoff from the configured delay, which is itself the relay's
|
|
203
|
+
// tightest documented spacing (60 000 / 10 AUTH per minute = 6 s).
|
|
204
|
+
// Doubling only widens it, so the retries can never be the thing that
|
|
205
|
+
// trips the limit they exist to survive.
|
|
206
|
+
const delayMs = config.retryDelayMs * 2 ** (attempt - 1);
|
|
207
|
+
log.warn('[MonitoringFallbackEmail] Temporary SMTP refusal, retrying', {
|
|
208
|
+
reason: error.message,
|
|
209
|
+
code: error.code,
|
|
210
|
+
response: error.response,
|
|
211
|
+
responseCode: error.responseCode,
|
|
212
|
+
attempt,
|
|
213
|
+
maxAttempts: config.maxAttempts,
|
|
214
|
+
retryInMs: delayMs
|
|
215
|
+
});
|
|
216
|
+
await wait(delayMs);
|
|
217
|
+
}
|
|
83
218
|
}
|
|
84
219
|
}
|
|
85
220
|
|
package/src/runtime-config.js
CHANGED
|
@@ -12,37 +12,165 @@
|
|
|
12
12
|
const runtimeCfg = require('./config');
|
|
13
13
|
const { createRuntimeConfig } = require('@onlineapps/runtime-config');
|
|
14
14
|
|
|
15
|
+
/** A bare env file name: `shared.env`, `monitoring.env`. No directory, no other suffix. */
|
|
16
|
+
const ENV_FILE_PATTERN = /^[A-Za-z0-9._-]+\.env$/;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Where an operator is told to set the key.
|
|
20
|
+
*
|
|
21
|
+
* With an owning file the sentence names it; without one it is the sentence
|
|
22
|
+
* `architecture-principles.md` §5 prescribes verbatim. The generic form is not a
|
|
23
|
+
* fallback for a missing input — it is the contract's own wording for a key
|
|
24
|
+
* whose file the call site did not name.
|
|
25
|
+
*
|
|
26
|
+
* The file is an INPUT and never a derivation. `api/config/shared-env.json` owns
|
|
27
|
+
* the platform's shared key set, but only build-time tooling reads it
|
|
28
|
+
* (`oa-sync-template shared-env`); it is absent from a service's runtime, and a
|
|
29
|
+
* library hunting for it through `__dirname`/cwd would break principle 1. So the
|
|
30
|
+
* call site names the file, and the declaration remains the authority that
|
|
31
|
+
* review checks the call site against.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} [file] - bare env file name, already validated
|
|
34
|
+
* @returns {string} the location half of a `Fix:` sentence
|
|
35
|
+
*/
|
|
36
|
+
function whereToSet(file) {
|
|
37
|
+
return file ? `config/env-active/${file}` : 'env-active/*.env (or pass explicit config)';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Validate `options.file` at method entry — a typo must fail on the happy path
|
|
42
|
+
* too, not only the day the variable goes missing (principle 4, fail-fast).
|
|
43
|
+
*
|
|
44
|
+
* @param {object} options - caller options
|
|
45
|
+
* @returns {string|undefined} the validated file name
|
|
46
|
+
*/
|
|
47
|
+
function owningFileOf(options) {
|
|
48
|
+
const file = options && options.file;
|
|
49
|
+
if (file === undefined || file === null) return undefined;
|
|
50
|
+
if (typeof file !== 'string' || !ENV_FILE_PATTERN.test(file)) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
'[ServiceConfig] Invalid options.file - expected a bare env file name such as '
|
|
53
|
+
+ `"shared.env", got: "${file}". Fix: pass the file name alone; the helper writes `
|
|
54
|
+
+ 'the config/env-active/ prefix.'
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return file;
|
|
58
|
+
}
|
|
59
|
+
|
|
15
60
|
/**
|
|
16
61
|
* Resolve arbitrary environment variables through runtime-config (no direct process.env access).
|
|
17
62
|
* Note: Prefer module schema keys (runtimeCfg.get) over these generic helpers.
|
|
63
|
+
*
|
|
64
|
+
* The `Fix:` half of the message is composed HERE, for every caller. Until d.399
|
|
65
|
+
* it was not: the helper appended only `description`, so the sentence
|
|
66
|
+
* `architecture-principles.md` §5 requires appeared exactly where a caller had
|
|
67
|
+
* typed it into that description by hand — six call sites in
|
|
68
|
+
* `infra/api_delivery_endpoint/src/config.js`, and nowhere else. A rule that
|
|
69
|
+
* holds only where somebody remembered it is enforced by review, not by a
|
|
70
|
+
* mechanism (`automation-gates.md` §5).
|
|
71
|
+
*
|
|
72
|
+
* @param {string} name - Environment variable name
|
|
73
|
+
* @param {string} [description] - Human-readable description for error messages
|
|
74
|
+
* @param {{file?: string}} [options] - `file`: the env file that owns this key,
|
|
75
|
+
* as a bare name (`shared.env`); the message writes the `config/env-active/`
|
|
76
|
+
* prefix itself
|
|
77
|
+
* @returns {string} Value resolved by `@onlineapps/runtime-config`
|
|
78
|
+
* @throws {Error} If the variable is missing, or `options.file` is malformed
|
|
18
79
|
*/
|
|
19
|
-
function requireEnv(name, description) {
|
|
80
|
+
function requireEnv(name, description, options) {
|
|
81
|
+
const file = owningFileOf(options);
|
|
82
|
+
// Klíč schématu je jméno proměnné, ne interní `value`. Resolver jiné jméno
|
|
83
|
+
// klíče nezná, a co mu helper předá, to napíše do hlášky: do d.466b psal
|
|
84
|
+
// `Invalid number value for "value"`, takže z hlášky nešlo přečíst, který klíč
|
|
85
|
+
// je špatně (INFRA, kaskáda 2026-09-15). Zdejší kolej žádné jiné jméno nemá —
|
|
86
|
+
// helper dostává proměnnou, ne konfigurační klíč — a vymyslet mu jméno by
|
|
87
|
+
// znamenalo psát do hlášky fakt, který nikdo nedeklaroval.
|
|
20
88
|
const cfg = createRuntimeConfig({
|
|
21
89
|
defaults: {},
|
|
22
|
-
schema: {
|
|
90
|
+
schema: { [name]: { env: name, required: true } },
|
|
23
91
|
});
|
|
24
92
|
try {
|
|
25
|
-
return cfg.get(
|
|
93
|
+
return cfg.get(name);
|
|
26
94
|
} catch (err) {
|
|
27
|
-
|
|
28
|
-
|
|
95
|
+
// Jeden šablonový literál, ne skládání polem: statická sonda
|
|
96
|
+
// `tests/unit/error-message-contract.test.js` čte zprávu ze zdroje, a
|
|
97
|
+
// hlášku, kterou si nepřečte, hlásí jako bezkontextovou — mechanismus, který
|
|
98
|
+
// nevidí, není mechanismus (`automation-gates.md` §5).
|
|
99
|
+
const hint = description ? `${description} ` : '';
|
|
100
|
+
throw new Error(
|
|
101
|
+
`[ServiceConfig] Missing environment variable - ${name} is required. ${hint}`
|
|
102
|
+
+ `Fix: set ${name} in ${whereToSet(file)}.`
|
|
103
|
+
);
|
|
29
104
|
}
|
|
30
105
|
}
|
|
31
106
|
|
|
32
107
|
/**
|
|
33
|
-
*
|
|
108
|
+
* Read a required environment variable through the resolver with a declared type.
|
|
109
|
+
*
|
|
110
|
+
* The missing-variable contract stays with `requireEnv` — all four required
|
|
111
|
+
* helpers in this file report an absent variable with the same sentence, so the
|
|
112
|
+
* operator reads one shape regardless of which type the key has. Coercion has
|
|
113
|
+
* exactly one owner, `@onlineapps/runtime-config`, and so does the message for a
|
|
114
|
+
* malformed value: a second parser here would be a second definition of what a
|
|
115
|
+
* number is (`.claude/rules/change-discipline.md` § One rail per concern).
|
|
116
|
+
*
|
|
34
117
|
* @param {string} name - Environment variable name
|
|
35
118
|
* @param {string} description - Human-readable description for error messages
|
|
36
|
-
* @
|
|
37
|
-
* @
|
|
119
|
+
* @param {'number'|'float'} type - Resolver type that owns the coercion
|
|
120
|
+
* @returns {number} Value coerced by the resolver
|
|
121
|
+
* @private
|
|
38
122
|
*/
|
|
39
|
-
function
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
123
|
+
function requireTypedEnv(name, description, type, options) {
|
|
124
|
+
requireEnv(name, description, options);
|
|
125
|
+
// `file` dojde až do schématu: hlášku o vadné hodnotě skládá resolver a bez
|
|
126
|
+
// deklarace nemá odkud vzít místo, kam má operátor sáhnout. Do d.466b se
|
|
127
|
+
// `options.file` použil jen na větu o CHYBĚJÍCÍ proměnné a pro vadnou hodnotu
|
|
128
|
+
// se zahodil, ačkoli obě věty mluví o témž klíči v témž souboru.
|
|
129
|
+
const file = owningFileOf(options);
|
|
130
|
+
const cfg = createRuntimeConfig({
|
|
131
|
+
defaults: {},
|
|
132
|
+
schema: { [name]: { env: name, required: true, type, ...(file ? { file } : {}) } },
|
|
133
|
+
});
|
|
134
|
+
return cfg.get(name);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Require a WHOLE-number environment variable (fail-fast, no default).
|
|
139
|
+
*
|
|
140
|
+
* Every platform key reading through this helper is a port, a count, a budget
|
|
141
|
+
* or a millisecond/second/minute interval — a fraction there is a configuration
|
|
142
|
+
* error, not something to round. A key that is genuinely fractional (a ratio, a
|
|
143
|
+
* factor) has its own rail: {@link requireFloatEnv}.
|
|
144
|
+
*
|
|
145
|
+
* The strictness itself lives in the resolver and follows its pin: up to
|
|
146
|
+
* `@onlineapps/runtime-config` 1.0.3 a fractional value is still truncated by
|
|
147
|
+
* `parseInt`, from the d.253 release it is refused.
|
|
148
|
+
*
|
|
149
|
+
* @param {string} name - Environment variable name
|
|
150
|
+
* @param {string} description - Human-readable description for error messages
|
|
151
|
+
* @returns {number} Whole number as resolved by `@onlineapps/runtime-config`
|
|
152
|
+
* @throws {Error} If the variable is missing, or its value is not a whole number
|
|
153
|
+
*/
|
|
154
|
+
function requireNumberEnv(name, description, options) {
|
|
155
|
+
return requireTypedEnv(name, description, 'number', options);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Require a FRACTIONAL environment variable (fail-fast, no default).
|
|
160
|
+
*
|
|
161
|
+
* For keys whose value is a ratio or a factor rather than a count — e.g. a
|
|
162
|
+
* completion ratio in `0.0-1.0`. Using {@link requireNumberEnv} for such a key
|
|
163
|
+
* loses the fraction (and, once the whole-number resolver is pinned, stops the
|
|
164
|
+
* boot), so the choice between the two rails is a property of the key and
|
|
165
|
+
* belongs at the call site.
|
|
166
|
+
*
|
|
167
|
+
* @param {string} name - Environment variable name
|
|
168
|
+
* @param {string} description - Human-readable description for error messages
|
|
169
|
+
* @returns {number} Fractional number as resolved by `@onlineapps/runtime-config`
|
|
170
|
+
* @throws {Error} If the variable is missing, or its value is not a number
|
|
171
|
+
*/
|
|
172
|
+
function requireFloatEnv(name, description, options) {
|
|
173
|
+
return requireTypedEnv(name, description, 'float', options);
|
|
46
174
|
}
|
|
47
175
|
|
|
48
176
|
/**
|
|
@@ -53,10 +181,13 @@ function requireNumberEnv(name, description) {
|
|
|
53
181
|
* @returns {boolean} Parsed boolean
|
|
54
182
|
* @throws {Error} If variable is missing or not "true"/"false"
|
|
55
183
|
*/
|
|
56
|
-
function requireBoolEnv(name, description) {
|
|
57
|
-
const raw = requireEnv(name, description);
|
|
184
|
+
function requireBoolEnv(name, description, options) {
|
|
185
|
+
const raw = requireEnv(name, description, options);
|
|
58
186
|
if (raw !== 'true' && raw !== 'false') {
|
|
59
|
-
throw new Error(
|
|
187
|
+
throw new Error(
|
|
188
|
+
`[ServiceConfig] ${name} must be "true" or "false" - got: "${raw}". `
|
|
189
|
+
+ `Fix: set ${name} to the literal true or false in ${whereToSet(owningFileOf(options))}.`
|
|
190
|
+
);
|
|
60
191
|
}
|
|
61
192
|
return raw === 'true';
|
|
62
193
|
}
|
|
@@ -71,9 +202,9 @@ function requireBoolEnv(name, description) {
|
|
|
71
202
|
function optionalEnv(name, defaultValue) {
|
|
72
203
|
const cfg = createRuntimeConfig({
|
|
73
204
|
defaults: {},
|
|
74
|
-
schema: {
|
|
205
|
+
schema: { [name]: { env: name, default: defaultValue } },
|
|
75
206
|
});
|
|
76
|
-
return cfg.get(
|
|
207
|
+
return cfg.get(name);
|
|
77
208
|
}
|
|
78
209
|
|
|
79
210
|
/**
|
|
@@ -85,9 +216,9 @@ function optionalEnv(name, defaultValue) {
|
|
|
85
216
|
function optionalNumberEnv(name, defaultValue) {
|
|
86
217
|
const cfg = createRuntimeConfig({
|
|
87
218
|
defaults: {},
|
|
88
|
-
schema: {
|
|
219
|
+
schema: { [name]: { env: name, default: defaultValue, type: 'number' } },
|
|
89
220
|
});
|
|
90
|
-
return cfg.get(
|
|
221
|
+
return cfg.get(name);
|
|
91
222
|
}
|
|
92
223
|
|
|
93
224
|
/**
|
|
@@ -104,17 +235,6 @@ function getCriticalConfig() {
|
|
|
104
235
|
};
|
|
105
236
|
}
|
|
106
237
|
|
|
107
|
-
/**
|
|
108
|
-
* Get critical infrastructure configuration with fallbacks
|
|
109
|
-
*
|
|
110
|
-
* Strict mode: This function is kept for compatibility but DOES NOT provide fallbacks.
|
|
111
|
-
* Topology is FAIL-FAST (no defaults).
|
|
112
|
-
* @returns {Object} Critical infrastructure config with fallbacks
|
|
113
|
-
*/
|
|
114
|
-
function getCriticalConfigWithFallbacks() {
|
|
115
|
-
return getCriticalConfig();
|
|
116
|
-
}
|
|
117
|
-
|
|
118
238
|
/**
|
|
119
239
|
* Get infrastructure health configuration with defaults
|
|
120
240
|
* @returns {Object} Infrastructure health config
|
|
@@ -126,7 +246,6 @@ function getInfrastructureHealthConfig() {
|
|
|
126
246
|
waitMaxTime: runtimeCfg.get('infrastructureHealthWaitMaxTimeMs'),
|
|
127
247
|
waitCheckInterval: runtimeCfg.get('infrastructureHealthWaitCheckIntervalMs'),
|
|
128
248
|
healthCheckTimeout: runtimeCfg.get('infrastructureHealthTimeoutMs'),
|
|
129
|
-
redisKeyTTL: runtimeCfg.get('infrastructureHealthRedisTtlSeconds'),
|
|
130
249
|
cleanupInterval: runtimeCfg.get('infrastructureHealthCleanupIntervalMs'),
|
|
131
250
|
queueWaitMaxTime: runtimeCfg.get('infrastructureHealthQueueWaitMaxTimeMs'),
|
|
132
251
|
queueWaitCheckInterval: runtimeCfg.get('infrastructureHealthQueueWaitCheckIntervalMs'),
|
|
@@ -136,11 +255,11 @@ function getInfrastructureHealthConfig() {
|
|
|
136
255
|
module.exports = {
|
|
137
256
|
requireEnv,
|
|
138
257
|
requireNumberEnv,
|
|
258
|
+
requireFloatEnv,
|
|
139
259
|
requireBoolEnv,
|
|
140
260
|
optionalEnv,
|
|
141
261
|
optionalNumberEnv,
|
|
142
262
|
getCriticalConfig,
|
|
143
|
-
getCriticalConfigWithFallbacks,
|
|
144
263
|
getInfrastructureHealthConfig
|
|
145
264
|
};
|
|
146
265
|
|
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
// See: docs/biz/70-contracts/error-handling.md
|
|
4
|
-
const ERROR_TYPES = {
|
|
5
|
-
TRANSIENT: 'TRANSIENT',
|
|
6
|
-
BUSINESS: 'BUSINESS',
|
|
7
|
-
FATAL: 'FATAL',
|
|
8
|
-
VALIDATION: 'VALIDATION',
|
|
9
|
-
TIMEOUT: 'TIMEOUT',
|
|
10
|
-
RATE_LIMIT: 'RATE_LIMIT',
|
|
11
|
-
UNKNOWN: 'UNKNOWN'
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
class BusinessError extends Error {
|
|
15
|
-
constructor(message, { statusCode = 500, errorCode = 'INTERNAL_ERROR', type = 'BUSINESS', details = [], operation = null, service = null } = {}) {
|
|
16
|
-
super(message);
|
|
17
|
-
this.name = this.constructor.name;
|
|
18
|
-
this.statusCode = statusCode;
|
|
19
|
-
this.errorCode = errorCode;
|
|
20
|
-
this.type = type;
|
|
21
|
-
this.details = Array.isArray(details) ? details : [details];
|
|
22
|
-
this.operation = operation || null;
|
|
23
|
-
this.service = service || process.env.SERVICE_NAME || null;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
toJSON() {
|
|
27
|
-
return {
|
|
28
|
-
code: this.errorCode,
|
|
29
|
-
message: this.message,
|
|
30
|
-
statusCode: this.statusCode,
|
|
31
|
-
details: this.details.length > 0 ? this.details : undefined,
|
|
32
|
-
service: this.service || undefined,
|
|
33
|
-
operation: this.operation || undefined
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
class NotFoundError extends BusinessError {
|
|
39
|
-
constructor(message, options = {}) {
|
|
40
|
-
super(message, {
|
|
41
|
-
statusCode: 404,
|
|
42
|
-
errorCode: options.errorCode || 'RESOURCE_NOT_FOUND',
|
|
43
|
-
type: ERROR_TYPES.BUSINESS,
|
|
44
|
-
...options
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
class ValidationError extends BusinessError {
|
|
50
|
-
constructor(message, options = {}) {
|
|
51
|
-
super(message, {
|
|
52
|
-
statusCode: 400,
|
|
53
|
-
errorCode: options.errorCode || 'VALIDATION_FAILED',
|
|
54
|
-
type: ERROR_TYPES.VALIDATION,
|
|
55
|
-
...options
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
class ConflictError extends BusinessError {
|
|
61
|
-
constructor(message, options = {}) {
|
|
62
|
-
super(message, {
|
|
63
|
-
statusCode: 409,
|
|
64
|
-
errorCode: options.errorCode || 'DUPLICATE_RESOURCE',
|
|
65
|
-
type: ERROR_TYPES.BUSINESS,
|
|
66
|
-
...options
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
class BusinessRuleError extends BusinessError {
|
|
72
|
-
constructor(message, options = {}) {
|
|
73
|
-
super(message, {
|
|
74
|
-
statusCode: 422,
|
|
75
|
-
errorCode: options.errorCode || 'BUSINESS_RULE_VIOLATED',
|
|
76
|
-
type: ERROR_TYPES.VALIDATION,
|
|
77
|
-
...options
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
class AuthorizationError extends BusinessError {
|
|
83
|
-
constructor(message, options = {}) {
|
|
84
|
-
super(message, {
|
|
85
|
-
statusCode: 403,
|
|
86
|
-
errorCode: options.errorCode || 'FORBIDDEN',
|
|
87
|
-
type: ERROR_TYPES.BUSINESS,
|
|
88
|
-
...options
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
class ServiceUnavailableError extends BusinessError {
|
|
94
|
-
constructor(message, options = {}) {
|
|
95
|
-
super(message, {
|
|
96
|
-
statusCode: 503,
|
|
97
|
-
errorCode: options.errorCode || 'SERVICE_UNAVAILABLE',
|
|
98
|
-
type: ERROR_TYPES.TRANSIENT,
|
|
99
|
-
...options
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function isBusinessError(error) {
|
|
105
|
-
return error instanceof BusinessError;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
module.exports = {
|
|
109
|
-
BusinessError,
|
|
110
|
-
NotFoundError,
|
|
111
|
-
ValidationError,
|
|
112
|
-
ConflictError,
|
|
113
|
-
BusinessRuleError,
|
|
114
|
-
AuthorizationError,
|
|
115
|
-
ServiceUnavailableError,
|
|
116
|
-
isBusinessError,
|
|
117
|
-
ERROR_TYPES
|
|
118
|
-
};
|