@mountainpass/addressr 2.5.2 → 2.6.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.
@@ -16,8 +16,22 @@ const HEADER_VAR = 'ADDRESSR_PROXY_AUTH_HEADER';
16
16
  const VALUE_VAR = 'ADDRESSR_PROXY_AUTH_VALUE';
17
17
 
18
18
  // Closed list per ADR 024: /health for monitoring, /api-docs for gateway
19
- // OpenAPI imports (ADR 023). Exact-match, not prefix.
20
- const ALLOWLIST = new Set(['/health', '/api-docs']);
19
+ // OpenAPI imports (ADR 023), /debug/shadow-config for operator diagnostics
20
+ // (P035). Exact-match, not prefix.
21
+ //
22
+ // Debug-endpoint policy (deferred from ADR 024 amendment per the P035 plan
23
+ // 2026-05-03; consolidate into ADR amendment when a 2nd /debug/* endpoint
24
+ // lands):
25
+ // 1. Each new /debug/<name> endpoint requires its own ALLOWLIST entry —
26
+ // no prefix matching, no `/debug/*` glob.
27
+ // 2. Response body must be bounded: booleans, integers, ISO timestamps,
28
+ // and closed enums only. No free-text error messages, no hostnames /
29
+ // IPs / ARNs, no stack traces. Information-disclosure analysis must
30
+ // be trivial by inspection of the response shape.
31
+ // 3. Every new debug endpoint adds a snapshot test in
32
+ // test/js/__tests__/proxy-auth.test.mjs covering both the new exempt
33
+ // path AND a "must NOT exempt" assertion to prevent accidental glob.
34
+ const ALLOWLIST = new Set(['/health', '/api-docs', '/debug/shadow-config']);
21
35
  function isNonEmpty(value) {
22
36
  return typeof value === 'string' && value.length > 0;
23
37
  }
@@ -4,6 +4,8 @@ Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
6
  exports._resetShadowClientForTesting = _resetShadowClientForTesting;
7
+ exports._resetShadowCountersForTesting = _resetShadowCountersForTesting;
8
+ exports.getShadowStatus = getShadowStatus;
7
9
  exports.mirrorRequest = mirrorRequest;
8
10
  exports.validateReadShadowConfig = validateReadShadowConfig;
9
11
  var _debug = _interopRequireDefault(require("debug"));
@@ -56,6 +58,20 @@ const DEFAULT_TIMEOUT_MS = 3000;
56
58
  // agents internally).
57
59
  let cachedClient;
58
60
  let cachedClientFingerprint;
61
+
62
+ // P035: in-memory counters surfaced via /debug/shadow-config so an operator
63
+ // can answer "is shadow firing?" in <200ms without ssh-ing into EB. Reset
64
+ // at process boot; CloudWatch alarms (separate P035 task) cover the
65
+ // persistent + cross-instance surface. Increments are JS-atomic on the
66
+ // single-threaded event loop; no shared-memory worker threads in this
67
+ // process, so no race protection needed. Number.MAX_SAFE_INTEGER is 2^53-1
68
+ // — counter overflow horizon at 1000 q/s is ~285,000 years; not a concern.
69
+ let shadowAttempts = 0;
70
+ let shadowSuccesses = 0;
71
+ let shadowFailures = 0;
72
+ /** @type {{ class: 'AbortError' | 'ConnectionError' | 'AuthError' | 'UnknownError', ts: string } | null} */
73
+ // eslint-disable-next-line unicorn/no-null -- explicit null preserves JSON serialization shape (`lastError: null` vs property dropped when undefined); contract documented in getShadowStatus JSDoc
74
+ let lastShadowError = null;
59
75
  function isNonEmpty(value) {
60
76
  return typeof value === 'string' && value.length > 0;
61
77
  }
@@ -79,7 +95,12 @@ function buildClientOptions(environment) {
79
95
  const protocol = environment[PROTOCOL_VAR] || 'https';
80
96
  const username = environment[USERNAME_VAR];
81
97
  const password = environment[PASSWORD_VAR];
82
- const node = isNonEmpty(username) ? `${protocol}://${username}:${password}@${host}:${port}` : `${protocol}://${host}:${port}`;
98
+ // P035 production-discovered bug: base64-derived passwords commonly contain
99
+ // '/', '+', '=', ':' which are URL-reserved. Without encoding, the resulting
100
+ // node URL `https://user:pa/ss@host:443` makes `new Client(...)` throw
101
+ // synchronously (URL parser treats '/' as path delimiter). Encode the
102
+ // credentials so any password the operator generates is safe.
103
+ const node = isNonEmpty(username) ? `${protocol}://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${host}:${port}` : `${protocol}://${host}:${port}`;
83
104
  return {
84
105
  node
85
106
  };
@@ -114,7 +135,27 @@ function getTimeoutMs(environment) {
114
135
  const parsed = Number.parseInt(raw, 10);
115
136
  return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TIMEOUT_MS;
116
137
  }
138
+
139
+ // Closed enum mapping (P035, ADR 024 information-disclosure remediation):
140
+ // arbitrary error shapes from the OpenSearch client get bucketed into a
141
+ // fixed-size set so the /debug/shadow-config response cannot leak free-text
142
+ // (which routinely contains hostnames, IPs, ARNs in OpenSearch errors).
143
+ function classifyError(reason) {
144
+ if (reason && reason.name === 'AbortError') return 'AbortError';
145
+ if (reason && (reason.code === 'ECONNREFUSED' || reason.code === 'ENOTFOUND' || reason.code === 'ETIMEDOUT' || reason.code === 'EAI_AGAIN' || reason.code === 'ECONNRESET')) {
146
+ return 'ConnectionError';
147
+ }
148
+ if (reason && (reason.statusCode === 401 || reason.statusCode === 403)) {
149
+ return 'AuthError';
150
+ }
151
+ return 'UnknownError';
152
+ }
117
153
  function swallowError(reason) {
154
+ shadowFailures += 1;
155
+ lastShadowError = {
156
+ class: classifyError(reason),
157
+ ts: new Date().toISOString()
158
+ };
118
159
  if (reason && reason.name === 'AbortError') {
119
160
  error('read-shadow: request aborted by timeout');
120
161
  return;
@@ -154,6 +195,7 @@ function mirrorRequest({
154
195
  if (!client) {
155
196
  return; // feature disabled (HOST unset)
156
197
  }
198
+ shadowAttempts += 1;
157
199
  const timeoutMs = getTimeoutMs(environment);
158
200
  const controller = new AbortController();
159
201
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -174,9 +216,18 @@ function mirrorRequest({
174
216
  clearTimeout(timer);
175
217
  return;
176
218
  }
219
+ // Architect §3 / risk R2: success-callback body is wrapped in try so a
220
+ // counter-increment exception (or any other synchronous throw) cannot
221
+ // propagate as an unhandled rejection. Failure paths land in swallowError
222
+ // via .catch which is itself try-shaped.
177
223
  promise.then(() => {
178
224
  clearTimeout(timer);
179
- logger('read-shadow: %s ok', method);
225
+ try {
226
+ shadowSuccesses += 1;
227
+ logger('read-shadow: %s ok', method);
228
+ } catch (callbackError) {
229
+ swallowError(callbackError);
230
+ }
180
231
  return;
181
232
  }).catch(error_ => {
182
233
  clearTimeout(timer);
@@ -184,9 +235,65 @@ function mirrorRequest({
184
235
  });
185
236
  }
186
237
 
238
+ /**
239
+ * Returns a snapshot of read-shadow runtime state for the
240
+ * `/debug/shadow-config` endpoint. Pure read of module-scoped state plus
241
+ * env-var presence checks; no I/O, no env-var values returned, no free-text
242
+ * error messages, no stack traces.
243
+ *
244
+ * Response shape:
245
+ * - `hostSet` / `credentialsSet`: booleans only — env vars present, not their values.
246
+ * - `clientConstructed`: true after the first `mirrorRequest` invocation that
247
+ * reached the client construction step (i.e., HOST set and shadow has been
248
+ * exercised at least once since boot).
249
+ * - `attempts` / `successes` / `failures`: integer counters since process start.
250
+ * Reset on process restart; CloudWatch covers persistent metrics.
251
+ * - `lastError.class`: closed enum, never free text. One of:
252
+ * - `'AbortError'` — request timeout (AbortController fired)
253
+ * - `'ConnectionError'` — TCP/TLS/DNS/network reach failure (ECONNREFUSED, ENOTFOUND, etc.)
254
+ * - `'AuthError'` — 401/403 from shadow target
255
+ * - `'UnknownError'` — anything else (rare; the catch-all)
256
+ * - `lastError.ts`: ISO 8601 timestamp of the last failure.
257
+ *
258
+ * P035 / ADR 024 information-disclosure remediation: the bounded enum
259
+ * mechanically prevents leakage of hostnames, IPs, ARNs, or stack traces
260
+ * even though the endpoint is on the proxy-auth ALLOWLIST.
261
+ *
262
+ * @returns {{
263
+ * hostSet: boolean,
264
+ * credentialsSet: boolean,
265
+ * clientConstructed: boolean,
266
+ * attempts: number,
267
+ * successes: number,
268
+ * failures: number,
269
+ * lastError: { class: 'AbortError' | 'ConnectionError' | 'AuthError' | 'UnknownError', ts: string } | null,
270
+ * }}
271
+ */
272
+ function getShadowStatus(environment = process.env) {
273
+ return {
274
+ hostSet: isNonEmpty(environment[HOST_VAR]),
275
+ credentialsSet: isNonEmpty(environment[USERNAME_VAR]) && isNonEmpty(environment[PASSWORD_VAR]),
276
+ clientConstructed: !!cachedClient,
277
+ attempts: shadowAttempts,
278
+ successes: shadowSuccesses,
279
+ failures: shadowFailures,
280
+ lastError: lastShadowError
281
+ };
282
+ }
283
+
187
284
  // Internal: lets unit tests reset the singleton between cases. Not part of
188
285
  // the public contract; flagged with a leading underscore by convention.
189
286
  function _resetShadowClientForTesting() {
190
287
  cachedClient = undefined;
191
288
  cachedClientFingerprint = undefined;
289
+ }
290
+
291
+ // Internal: lets unit tests reset counters between cases. Same convention
292
+ // as _resetShadowClientForTesting.
293
+ function _resetShadowCountersForTesting() {
294
+ shadowAttempts = 0;
295
+ shadowSuccesses = 0;
296
+ shadowFailures = 0;
297
+ // eslint-disable-next-line unicorn/no-null -- match initial value semantics for the public lastError contract
298
+ lastShadowError = null;
192
299
  }
@@ -987,6 +987,22 @@ function startRest2Server() {
987
987
  };
988
988
  }
989
989
  });
990
+
991
+ // P035: operator-diagnostic endpoint for read-shadow runtime introspection.
992
+ // Returns config-presence booleans + counters + closed-enum lastError;
993
+ // never returns hostnames, secrets, or free-text error messages.
994
+ // ALLOWLIST'd in src/proxy-auth.js per the debug-endpoint policy.
995
+ waycharter.registerResourceType({
996
+ path: '/debug/shadow-config',
997
+ loader: async () => {
998
+ return {
999
+ body: (0, _readShadow.getShadowStatus)(),
1000
+ headers: {
1001
+ 'cache-control': 'no-cache'
1002
+ }
1003
+ };
1004
+ }
1005
+ });
990
1006
  waycharter.registerResourceType({
991
1007
  path: '/',
992
1008
  loader: async () => {
package/lib/version.js CHANGED
@@ -5,4 +5,4 @@ Object.defineProperty(exports, "__esModule", {
5
5
  });
6
6
  exports.version = void 0;
7
7
  // Generated by genversion.
8
- const version = exports.version = '2.5.2';
8
+ const version = exports.version = '2.6.1';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mountainpass/addressr",
3
- "version": "2.5.2",
3
+ "version": "2.6.1",
4
4
  "description": "Australian Address Validation, Search and Autocomplete",
5
5
  "author": {
6
6
  "name": "Mountain Pass",