@mountainpass/addressr 2.5.1 → 2.6.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.
@@ -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
  }
@@ -114,7 +130,27 @@ function getTimeoutMs(environment) {
114
130
  const parsed = Number.parseInt(raw, 10);
115
131
  return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TIMEOUT_MS;
116
132
  }
133
+
134
+ // Closed enum mapping (P035, ADR 024 information-disclosure remediation):
135
+ // arbitrary error shapes from the OpenSearch client get bucketed into a
136
+ // fixed-size set so the /debug/shadow-config response cannot leak free-text
137
+ // (which routinely contains hostnames, IPs, ARNs in OpenSearch errors).
138
+ function classifyError(reason) {
139
+ if (reason && reason.name === 'AbortError') return 'AbortError';
140
+ if (reason && (reason.code === 'ECONNREFUSED' || reason.code === 'ENOTFOUND' || reason.code === 'ETIMEDOUT' || reason.code === 'EAI_AGAIN' || reason.code === 'ECONNRESET')) {
141
+ return 'ConnectionError';
142
+ }
143
+ if (reason && (reason.statusCode === 401 || reason.statusCode === 403)) {
144
+ return 'AuthError';
145
+ }
146
+ return 'UnknownError';
147
+ }
117
148
  function swallowError(reason) {
149
+ shadowFailures += 1;
150
+ lastShadowError = {
151
+ class: classifyError(reason),
152
+ ts: new Date().toISOString()
153
+ };
118
154
  if (reason && reason.name === 'AbortError') {
119
155
  error('read-shadow: request aborted by timeout');
120
156
  return;
@@ -154,6 +190,7 @@ function mirrorRequest({
154
190
  if (!client) {
155
191
  return; // feature disabled (HOST unset)
156
192
  }
193
+ shadowAttempts += 1;
157
194
  const timeoutMs = getTimeoutMs(environment);
158
195
  const controller = new AbortController();
159
196
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -174,9 +211,18 @@ function mirrorRequest({
174
211
  clearTimeout(timer);
175
212
  return;
176
213
  }
214
+ // Architect §3 / risk R2: success-callback body is wrapped in try so a
215
+ // counter-increment exception (or any other synchronous throw) cannot
216
+ // propagate as an unhandled rejection. Failure paths land in swallowError
217
+ // via .catch which is itself try-shaped.
177
218
  promise.then(() => {
178
219
  clearTimeout(timer);
179
- logger('read-shadow: %s ok', method);
220
+ try {
221
+ shadowSuccesses += 1;
222
+ logger('read-shadow: %s ok', method);
223
+ } catch (callbackError) {
224
+ swallowError(callbackError);
225
+ }
180
226
  return;
181
227
  }).catch(error_ => {
182
228
  clearTimeout(timer);
@@ -184,9 +230,65 @@ function mirrorRequest({
184
230
  });
185
231
  }
186
232
 
233
+ /**
234
+ * Returns a snapshot of read-shadow runtime state for the
235
+ * `/debug/shadow-config` endpoint. Pure read of module-scoped state plus
236
+ * env-var presence checks; no I/O, no env-var values returned, no free-text
237
+ * error messages, no stack traces.
238
+ *
239
+ * Response shape:
240
+ * - `hostSet` / `credentialsSet`: booleans only — env vars present, not their values.
241
+ * - `clientConstructed`: true after the first `mirrorRequest` invocation that
242
+ * reached the client construction step (i.e., HOST set and shadow has been
243
+ * exercised at least once since boot).
244
+ * - `attempts` / `successes` / `failures`: integer counters since process start.
245
+ * Reset on process restart; CloudWatch covers persistent metrics.
246
+ * - `lastError.class`: closed enum, never free text. One of:
247
+ * - `'AbortError'` — request timeout (AbortController fired)
248
+ * - `'ConnectionError'` — TCP/TLS/DNS/network reach failure (ECONNREFUSED, ENOTFOUND, etc.)
249
+ * - `'AuthError'` — 401/403 from shadow target
250
+ * - `'UnknownError'` — anything else (rare; the catch-all)
251
+ * - `lastError.ts`: ISO 8601 timestamp of the last failure.
252
+ *
253
+ * P035 / ADR 024 information-disclosure remediation: the bounded enum
254
+ * mechanically prevents leakage of hostnames, IPs, ARNs, or stack traces
255
+ * even though the endpoint is on the proxy-auth ALLOWLIST.
256
+ *
257
+ * @returns {{
258
+ * hostSet: boolean,
259
+ * credentialsSet: boolean,
260
+ * clientConstructed: boolean,
261
+ * attempts: number,
262
+ * successes: number,
263
+ * failures: number,
264
+ * lastError: { class: 'AbortError' | 'ConnectionError' | 'AuthError' | 'UnknownError', ts: string } | null,
265
+ * }}
266
+ */
267
+ function getShadowStatus(environment = process.env) {
268
+ return {
269
+ hostSet: isNonEmpty(environment[HOST_VAR]),
270
+ credentialsSet: isNonEmpty(environment[USERNAME_VAR]) && isNonEmpty(environment[PASSWORD_VAR]),
271
+ clientConstructed: !!cachedClient,
272
+ attempts: shadowAttempts,
273
+ successes: shadowSuccesses,
274
+ failures: shadowFailures,
275
+ lastError: lastShadowError
276
+ };
277
+ }
278
+
187
279
  // Internal: lets unit tests reset the singleton between cases. Not part of
188
280
  // the public contract; flagged with a leading underscore by convention.
189
281
  function _resetShadowClientForTesting() {
190
282
  cachedClient = undefined;
191
283
  cachedClientFingerprint = undefined;
284
+ }
285
+
286
+ // Internal: lets unit tests reset counters between cases. Same convention
287
+ // as _resetShadowClientForTesting.
288
+ function _resetShadowCountersForTesting() {
289
+ shadowAttempts = 0;
290
+ shadowSuccesses = 0;
291
+ shadowFailures = 0;
292
+ // eslint-disable-next-line unicorn/no-null -- match initial value semantics for the public lastError contract
293
+ lastShadowError = null;
192
294
  }
@@ -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.1';
8
+ const version = exports.version = '2.6.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mountainpass/addressr",
3
- "version": "2.5.1",
3
+ "version": "2.6.0",
4
4
  "description": "Australian Address Validation, Search and Autocomplete",
5
5
  "author": {
6
6
  "name": "Mountain Pass",