actual-mcp-server 0.7.13 → 0.8.3

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
@@ -449,6 +449,7 @@ All configuration is via environment variables. Copy `.env.example` to `.env` to
449
449
  | `ACTUAL_BUDGET_SYNC_ID` | _(none)_ | Yes | Budget Sync ID from Actual (Settings then Sync ID) |
450
450
  | `ACTUAL_BUDGET_PASSWORD` | _(none)_ | No | Optional encryption password for encrypted budgets |
451
451
  | `ALLOW_INSECURE_UPSTREAM` | `false` | No | Allow an `http://` upstream even when `ACTUAL_BUDGET_PASSWORD` is set (#161). Off by default so a plaintext upstream plus an encryption password is refused |
452
+ | `ACTUAL_OP_TIMEOUT_MS` | `30000` | No | Per-operation timeout (ms) for every upstream Actual API call (init, budget download, sync, and each tool operation). A stalled call rejects after this bound so it cannot hold the global API mutex forever and hang subsequent tool calls (#270). Set to `0` to disable |
452
453
  | **MCP Server Settings** ||||
453
454
  | `MCP_BRIDGE_PORT` | `3600` | No | Port for MCP server to listen on |
454
455
  | `MCP_BRIDGE_BIND_HOST` | `0.0.0.0` | No | Host address to bind server to (`0.0.0.0` = all interfaces) |
@@ -473,6 +474,7 @@ All configuration is via environment variables. Copy `.env.example` to `.env` to
473
474
  | `OIDC_ALLOW_INSECURE_ISSUER` | `false` | No | Allow a plaintext (http) OIDC issuer on a trusted network (#244). Off by default (http issuer refused at startup); set `true` only for local/LAN testing |
474
475
  | `OIDC_RESOURCE` | _(none)_ | No | Expected `aud` claim in JWT (your client ID) |
475
476
  | `OIDC_ACCEPTED_AUDIENCES` | _(none)_ | No | Extra accepted `aud` values beyond `OIDC_RESOURCE`, comma-separated (#245). For IdPs that put the client-id in `aud` (e.g. Authentik). Strict allowlist, never a wildcard |
477
+ | `OIDC_JWKS_TRUSTED_HOSTS` | _(none)_ | No | Opt-in cross-origin JWKS hosts, comma-separated `host` or `host:port` (#254). For IdPs whose `jwks_uri` lives on another host, e.g. Google needs `www.googleapis.com`. Exact match, no wildcards; empty default keeps same-origin-only |
476
478
  | `OIDC_SCOPES` | _(none)_ | No | Comma-separated required scopes; leave empty for Casdoor |
477
479
  | `AUTH_BUDGET_ACL` | _(none)_ | No | Per-user budget ACL; see [AI Client Setup](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-multi-user) |
478
480
  | `MCP_ENABLE_HTTPS` | `false` | No | Enable native TLS. Requires `MCP_HTTPS_CERT` and `MCP_HTTPS_KEY` |
@@ -718,4 +720,4 @@ The software is provided **as-is**, without warranty of any kind. The author acc
718
720
 
719
721
  ---
720
722
 
721
- **Version:** 0.7.13 | **Tool Count:** 71 (verified LibreChat-compatible)
723
+ **Version:** 0.8.3 | **Tool Count:** 71 (verified LibreChat-compatible)
package/dist/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "actual-mcp-server",
3
3
  "displayName": "Actual MCP Server",
4
- "version": "0.7.13",
4
+ "version": "0.8.3",
5
5
  "engines": {
6
6
  "node": ">=22.0.0",
7
7
  "npm": ">=10.0.0"
@@ -30,7 +30,7 @@
30
30
  "verify-tools": "npm run build && node scripts/verify-tools.js",
31
31
  "check:coverage": "node scripts/list-actual-api-methods.mjs",
32
32
  "direct-sync": "node scripts/direct-sync/bank-sync-direct.mjs",
33
- "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js",
33
+ "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js",
34
34
  "test:adapter": "npm run build && node dist/src/tests_adapter_runner.js",
35
35
  "test:e2e": "npx playwright test",
36
36
  "test:e2e:docker": "./tests/e2e/run-docker-e2e.sh",
@@ -78,7 +78,7 @@
78
78
  "devDependencies": {
79
79
  "@playwright/test": "^1.60.0",
80
80
  "@types/express": "^5.0.3",
81
- "@types/node": "^25.9.2",
81
+ "@types/node": "^26.1.0",
82
82
  "knip": "^6.16.1",
83
83
  "node-fetch": "^3.3.2",
84
84
  "tsconfig-paths": "^4.2.0",
@@ -5,6 +5,30 @@ export const configSchema = z.object({
5
5
  ACTUAL_BUDGET_SYNC_ID: z.string().min(1),
6
6
  // Optional per-budget encryption password (leave unset for unencrypted budgets)
7
7
  ACTUAL_BUDGET_PASSWORD: z.string().optional(),
8
+ // #270: bound every upstream Actual API operation (api.init, downloadBudget,
9
+ // sync, and each tool operation body) so a stalled call cannot hold the
10
+ // process-global api mutex forever and hang all subsequent tool calls. On
11
+ // timeout the operation rejects with a clear error, the mutex releases, and
12
+ // later calls proceed. Default 30000ms (30s): generous for a healthy
13
+ // init+download on a large budget, short enough that a stalled upstream
14
+ // recovers in seconds instead of wedging the session. Set to 0 to disable the
15
+ // bound (not recommended). A non-numeric or negative value falls back to the
16
+ // default. `0` disables the bound. Any other positive value is clamped to the
17
+ // range [250ms, 2147483647ms]:
18
+ // - Floor 250ms: a smaller positive value would time out real operations
19
+ // almost immediately (a self-inflicted DoS footgun, e.g. a typo'd `25`).
20
+ // Use `0` to disable deliberately rather than a tiny value.
21
+ // - Ceiling 2147483647ms (~24.8 days, the 32-bit setTimeout max): a larger
22
+ // value overflows setTimeout and silently clamps to 1ms, timing out
23
+ // everything.
24
+ ACTUAL_OP_TIMEOUT_MS: z.string().default('30000').transform(val => {
25
+ const n = parseInt(val, 10);
26
+ if (!Number.isFinite(n) || n < 0)
27
+ return 30000;
28
+ if (n === 0)
29
+ return 0;
30
+ return Math.min(Math.max(n, 250), 2147483647);
31
+ }),
8
32
  // Escape hatch for #161: allow an http:// upstream even when an E2E encryption
9
33
  // password is set (e.g. an isolated Docker network where the hop is trusted).
10
34
  // Off by default so a plaintext upstream + encryption password is refused.
@@ -46,6 +70,11 @@ export const configSchema = z.object({
46
70
  // Strict closed allowlist: the accepted set is OIDC_RESOURCE plus these; never a
47
71
  // wildcard. Default empty, so single-audience deployments are unchanged.
48
72
  OIDC_ACCEPTED_AUDIENCES: z.string().optional(),
73
+ // #254: opt-in allowlist of cross-origin JWKS hosts, comma-separated `host` or
74
+ // `host:port` entries (e.g. "www.googleapis.com" for a Google issuer). Raw string
75
+ // here; parsed fail-fast by buildTrustedJwksHosts (oidc-discovery.ts) at the
76
+ // composition root, per the #245 pattern. Default empty: same-origin only.
77
+ OIDC_JWKS_TRUSTED_HOSTS: z.string().optional(),
49
78
  // Comma-separated required scopes (e.g. "read,write"). Optional.
50
79
  OIDC_SCOPES: z.string().optional(),
51
80
  // JSON map of principal → budget sync-ID list for per-user budget ACL.
@@ -17,6 +17,7 @@ import path from 'path';
17
17
  import os from 'os';
18
18
  import fs from 'fs';
19
19
  import { isApiInitialized, setApiInitialized } from './apiState.js';
20
+ import { withOpTimeout } from './opTimeout.js';
20
21
  const DEFAULT_DATA_DIR = path.resolve(os.homedir() || '.', '.actual');
21
22
  class ActualConnectionPool {
22
23
  connections = new Map();
@@ -214,22 +215,27 @@ class ActualConnectionPool {
214
215
  fs.mkdirSync(DATA_DIR, { recursive: true });
215
216
  }
216
217
  try {
217
- await api.init({
218
+ // Bound the session-open init/download (#270): this is the HTTP exposure.
219
+ // A stalled upstream here would otherwise hang session open unbounded and,
220
+ // because the api singleton is process-global, wedge other sessions too.
221
+ await withOpTimeout(() => api.init({
218
222
  dataDir: DATA_DIR,
219
223
  serverURL: SERVER_URL,
220
224
  password: PASSWORD,
221
- });
222
- // Mark the singleton as live so the adapter's pool-cooperation branch
223
- // (withActualApi in actual-adapter.ts) can safely skip its per-op init.
224
- setApiInitialized(true);
225
+ }), 'pool init');
225
226
  logger.info(`[ConnectionPool] Downloading budget for session: ${sessionId}`);
226
227
  if (BUDGET_PASSWORD) {
227
228
  const apiWithOptions = api;
228
- await apiWithOptions.downloadBudget(BUDGET_SYNC_ID, { password: BUDGET_PASSWORD });
229
+ await withOpTimeout(() => apiWithOptions.downloadBudget(BUDGET_SYNC_ID, { password: BUDGET_PASSWORD }), 'pool downloadBudget');
229
230
  }
230
231
  else {
231
- await api.downloadBudget(BUDGET_SYNC_ID);
232
+ await withOpTimeout(() => api.downloadBudget(BUDGET_SYNC_ID), 'pool downloadBudget');
232
233
  }
234
+ // Mark the singleton as live only AFTER the budget is downloaded (#270):
235
+ // setting it before downloadBudget left a poisoning window where the
236
+ // adapter's pool-cooperation branch could reuse a connection whose budget
237
+ // never loaded (a stalled/failed download would leak isApiInitialized=true).
238
+ setApiInitialized(true);
233
239
  conn = {
234
240
  sessionId,
235
241
  initialized: true,
@@ -284,19 +290,22 @@ class ActualConnectionPool {
284
290
  fs.mkdirSync(DATA_DIR, { recursive: true });
285
291
  }
286
292
  try {
287
- await api.init({
293
+ // Bound the session-open init/download (#270), same as getConnection.
294
+ await withOpTimeout(() => api.init({
288
295
  dataDir: DATA_DIR,
289
296
  serverURL: SERVER_URL,
290
297
  password: PASSWORD,
291
- });
292
- setApiInitialized(true);
298
+ }), 'pool init');
293
299
  if (BUDGET_PASSWORD) {
294
300
  const apiWithOptions = api;
295
- await apiWithOptions.downloadBudget(BUDGET_SYNC_ID, { password: BUDGET_PASSWORD });
301
+ await withOpTimeout(() => apiWithOptions.downloadBudget(BUDGET_SYNC_ID, { password: BUDGET_PASSWORD }), 'pool downloadBudget');
296
302
  }
297
303
  else {
298
- await api.downloadBudget(BUDGET_SYNC_ID);
304
+ await withOpTimeout(() => api.downloadBudget(BUDGET_SYNC_ID), 'pool downloadBudget');
299
305
  }
306
+ // Mark live only after a successful download (#270): avoids the poisoning
307
+ // window where a stalled/failed download would leak isApiInitialized=true.
308
+ setApiInitialized(true);
300
309
  this.sharedConnection = {
301
310
  sessionId: 'shared',
302
311
  initialized: true,
@@ -9,6 +9,7 @@ const { addTransactions: rawAddTransactions, getAccounts: rawGetAccounts, import
9
9
  import { EventEmitter } from 'events';
10
10
  import observability from '../observability.js';
11
11
  import { retry, isRetryableError } from './retry.js';
12
+ import { withOpTimeout } from './opTimeout.js';
12
13
  import { notFoundMsg } from './errors.js';
13
14
  import logger from '../logger.js';
14
15
  import config from '../config.js';
@@ -88,6 +89,9 @@ function withApiLock(fn) {
88
89
  _apiSessionLock = new Promise(resolve => { release = resolve; });
89
90
  return prevLock.then(() => fn()).finally(() => release());
90
91
  }
92
+ // Per-op timeout (#270) lives in ./opTimeout.ts so ActualConnectionPool can bound
93
+ // its own session-open init/download without a circular import back into this
94
+ // module (which imports the pool singleton).
91
95
  // ----------------------------------------------------------------------------
92
96
  // Per-session pool cooperation — issue #134
93
97
  // ----------------------------------------------------------------------------
@@ -237,7 +241,7 @@ export async function withActualApi(operation) {
237
241
  try {
238
242
  connectionReuseCount++;
239
243
  logger.debug(`[ADAPTER] Reusing pool connection for session ${sessionId} (reuses=${connectionReuseCount})`);
240
- return await operation();
244
+ return await withOpTimeout(operation);
241
245
  }
242
246
  catch (err) {
243
247
  // Only drop the pool connection on errors that suggest the api
@@ -262,7 +266,7 @@ export async function withActualApi(operation) {
262
266
  return withApiLock(async () => {
263
267
  try {
264
268
  await initActualApiForOperation();
265
- return await operation();
269
+ return await withOpTimeout(operation);
266
270
  }
267
271
  finally {
268
272
  await shutdownActualApi();
@@ -290,13 +294,13 @@ export async function withActualApiWrite(operation) {
290
294
  try {
291
295
  connectionReuseCount++;
292
296
  logger.debug(`[ADAPTER] Reusing pool connection for write session ${sessionId} (reuses=${connectionReuseCount})`);
293
- const result = await operation();
297
+ const result = await withOpTimeout(operation);
294
298
  // Propagate the write to the server so other clients (and our next
295
299
  // read) see it. Pre-#134 this happened implicitly via api.shutdown().
296
300
  try {
297
301
  const apiAny = api;
298
302
  if (typeof apiAny.sync === 'function') {
299
- await apiAny.sync();
303
+ await withOpTimeout(() => apiAny.sync(), 'sync');
300
304
  }
301
305
  }
302
306
  catch (syncErr) {
@@ -332,7 +336,7 @@ export async function withActualApiWrite(operation) {
332
336
  return withApiLock(async () => {
333
337
  try {
334
338
  await initActualApiForOperation();
335
- return await operation();
339
+ return await withOpTimeout(operation);
336
340
  }
337
341
  finally {
338
342
  await shutdownActualApi();
@@ -420,18 +424,27 @@ async function initActualApiForOperation() {
420
424
  logger.debug(`[ADAPTER] Initializing Actual API for operation (budget: "${budget.name}", server: ${budget.serverUrl})`);
421
425
  // Wrap api.init in auth-rate-limit retry so a transient too-many-requests
422
426
  // doesn't surface to the caller (and doesn't trigger #132's crash path).
423
- await withAuthRetry(() => api.init({
427
+ // Bound EACH init attempt with the op timeout (#270), inside withAuthRetry:
428
+ // a stalled login rejects per attempt, and a timeout is not an auth error so
429
+ // withAuthRetry does not retry it. Wrapping per-attempt (not the whole retry
430
+ // loop) means legitimate #127 auth-rate-limit backoff (up to ~25s) is not
431
+ // counted against ACTUAL_OP_TIMEOUT_MS.
432
+ await withAuthRetry(() => withOpTimeout(() => api.init({
424
433
  dataDir: DATA_DIR,
425
434
  serverURL: budget.serverUrl,
426
435
  password: budget.password || '',
427
- }));
436
+ }), 'init'));
428
437
  logger.debug('[ADAPTER] Downloading budget');
438
+ // Bound downloadBudget (#270): the legacy stdio path re-downloads on every
439
+ // op, and a stall here was the production hang. On timeout it rejects and
440
+ // the mutex releases.
429
441
  if (budget.encryptionPassword) {
442
+ const encryptionPassword = budget.encryptionPassword;
430
443
  const apiWithOptions = api;
431
- await apiWithOptions.downloadBudget(budget.syncId, { password: budget.encryptionPassword });
444
+ await withOpTimeout(() => apiWithOptions.downloadBudget(budget.syncId, { password: encryptionPassword }), 'downloadBudget');
432
445
  }
433
446
  else {
434
- await api.downloadBudget(budget.syncId);
447
+ await withOpTimeout(() => api.downloadBudget(budget.syncId), 'downloadBudget');
435
448
  }
436
449
  setApiInitialized(true);
437
450
  logger.debug('[ADAPTER] Actual API initialized for operation');
@@ -459,7 +472,7 @@ async function shutdownActualApi() {
459
472
  try {
460
473
  const apiAny = api;
461
474
  if (typeof apiAny.sync === 'function') {
462
- await apiAny.sync();
475
+ await withOpTimeout(() => apiAny.sync(), 'sync');
463
476
  logger.debug('[ADAPTER] api.sync() instead of shutdown (pool has active sessions)');
464
477
  }
465
478
  }
@@ -477,7 +490,10 @@ async function shutdownActualApi() {
477
490
  try {
478
491
  const maybeApi = api;
479
492
  if (typeof maybeApi.shutdown === 'function') {
480
- await maybeApi.shutdown();
493
+ // Bound shutdown too (#270): it runs inside withApiLock, so a stalled
494
+ // shutdown would hold the mutex just like a stalled op. Errors here are
495
+ // already best-effort (swallowed below), and a timeout is one of them.
496
+ await withOpTimeout(() => maybeApi.shutdown(), 'shutdown');
481
497
  logger.debug('[ADAPTER] Actual API shutdown complete');
482
498
  }
483
499
  }
@@ -546,7 +562,10 @@ async function processWriteQueue() {
546
562
  // Each operation handles its own success/failure
547
563
  await Promise.allSettled(batch.map(async ({ operation, resolve, reject }) => {
548
564
  try {
549
- const result = await operation();
565
+ // Bound each queued write (#270): a stalled op must reject its own
566
+ // promise (so withWriteSession callers don't hang) and let the
567
+ // batch settle so the api mutex releases.
568
+ const result = await withOpTimeout(operation);
550
569
  resolve(result);
551
570
  }
552
571
  catch (error) {
@@ -558,7 +577,7 @@ async function processWriteQueue() {
558
577
  // before returning (pool). Persistence guarantee in both branches.
559
578
  logger.debug(`[WRITE QUEUE] Syncing ${batch.length} operations to server`);
560
579
  try {
561
- await api.sync();
580
+ await withOpTimeout(() => api.sync(), 'sync');
562
581
  logger.debug(`[WRITE QUEUE] Sync completed`);
563
582
  }
564
583
  catch (syncError) {
@@ -1680,12 +1699,14 @@ export async function switchBudget(name) {
1680
1699
  }
1681
1700
  else {
1682
1701
  await withApiLock(async () => {
1702
+ // Bound the in-place budget reload too (#270).
1683
1703
  if (found.encryptionPassword) {
1704
+ const encryptionPassword = found.encryptionPassword;
1684
1705
  const apiWithOptions = api;
1685
- await apiWithOptions.downloadBudget(found.syncId, { password: found.encryptionPassword });
1706
+ await withOpTimeout(() => apiWithOptions.downloadBudget(found.syncId, { password: encryptionPassword }), 'downloadBudget');
1686
1707
  }
1687
1708
  else {
1688
- await api.downloadBudget(found.syncId);
1709
+ await withOpTimeout(() => api.downloadBudget(found.syncId), 'downloadBudget');
1689
1710
  }
1690
1711
  });
1691
1712
  }
@@ -14,12 +14,14 @@ import { createModuleLogger } from './loggerFactory.js';
14
14
  * - the resolved `jwks_uri` must be https and on the SAME host as the issuer, so
15
15
  * a tampered or misconfigured discovery document cannot point key fetching at
16
16
  * an attacker-controlled host. Cross-host issuers (for example Google, which
17
- * serves keys from googleapis.com) are intentionally not auto-trusted; that is
18
- * a separate, configurable follow-up (#245).
17
+ * serves keys from googleapis.com) are never auto-trusted; the operator can
18
+ * allowlist specific cross-origin JWKS hosts via OIDC_JWKS_TRUSTED_HOSTS
19
+ * (#254, opt-in, exact host:port match, https required unconditionally).
19
20
  *
20
- * The pure helpers (assertSecureIssuer, resolveJwksUri) are unit-tested without a
21
- * network; discoverJwksUri wraps them around a single startup fetch and fails
22
- * closed on any problem.
21
+ * The helpers (assertSecureIssuer, buildTrustedJwksHosts, resolveJwksUri) are
22
+ * network-free and unit-tested directly (resolveJwksUri logs on the allowlist
23
+ * accept path, otherwise side-effect free); discoverJwksUri wraps them around a
24
+ * single startup fetch and fails closed on any problem.
23
25
  */
24
26
  const logger = createModuleLogger('OIDC_DISCOVERY');
25
27
  const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
@@ -28,6 +30,47 @@ const DISCOVERY_TIMEOUT_MS = 10_000;
28
30
  export function isLoopbackHost(host) {
29
31
  return host ? LOOPBACK_HOSTS.has(host.toLowerCase()) : false;
30
32
  }
33
+ /**
34
+ * Parse OIDC_JWKS_TRUSTED_HOSTS (comma-separated `host` or `host:port`
35
+ * entries) into a normalized, deduplicated allowlist (#254). Mirrors the #245
36
+ * buildAcceptedAudiences pattern: config stays a raw string, parsing happens
37
+ * here, and the result is threaded as a parameter. Entries are trimmed,
38
+ * lowercased, and empties dropped, then validated by a URL round-trip: an
39
+ * entry must equal `new URL('https://' + entry).host`, which rejects in one
40
+ * rule everything that could never match a real jwks_uri host: schemes,
41
+ * paths, credentials, wildcards, whitespace, empty or non-numeric ports,
42
+ * default-port `:443` suffixes (URL.host elides them, so such an entry would
43
+ * be silently inert), raw-unicode IDN (URL.host is punycode), and unbracketed
44
+ * IPv6. THROWS on the first invalid entry so a misconfigured allowlist fails
45
+ * fast at startup instead of silently failing closed at the first
46
+ * cross-origin issuer.
47
+ */
48
+ export function buildTrustedJwksHosts(configured) {
49
+ if (!configured)
50
+ return [];
51
+ const entries = configured
52
+ .split(',')
53
+ .map((entry) => entry.trim().toLowerCase())
54
+ .filter(Boolean);
55
+ for (const entry of entries) {
56
+ let canonical;
57
+ try {
58
+ canonical = new URL(`https://${entry}`).host;
59
+ }
60
+ catch {
61
+ canonical = undefined;
62
+ }
63
+ // WHATWG URL does not forbid "*" in a hostname, so the round-trip alone
64
+ // would let a wildcard through; there is deliberately no wildcard matching.
65
+ if (entry.includes('*') || canonical !== entry) {
66
+ throw new Error(`OIDC_JWKS_TRUSTED_HOSTS entry is invalid: "${entry}". Entries must be canonical URL ` +
67
+ 'hosts: a bare lowercase hostname, optionally with a NON-default port (host:port; ' +
68
+ '":443" is elided by URL parsing and would never match), no scheme, path, ' +
69
+ 'credentials, or wildcards; IDN as punycode; IPv6 in brackets.');
70
+ }
71
+ }
72
+ return Array.from(new Set(entries));
73
+ }
31
74
  /**
32
75
  * Parse and validate the issuer URL. Throws unless it is https, or an explicit
33
76
  * loopback host (local dev). Returns the parsed URL.
@@ -62,11 +105,16 @@ export function assertSecureIssuer(issuer, allowInsecure = false) {
62
105
  return url;
63
106
  }
64
107
  /**
65
- * Pure: resolve and validate the `jwks_uri` from a parsed discovery document
66
- * against the issuer. Throws (fail closed) if it is missing, not https, carries
67
- * embedded credentials, or is not same-origin (scheme + host + port) with the issuer.
108
+ * Network-free: resolve and validate the `jwks_uri` from a parsed discovery
109
+ * document against the issuer. Throws (fail closed) if it is missing, not
110
+ * https, carries embedded credentials, or is not same-origin (scheme + host +
111
+ * port) with the issuer, UNLESS the jwks host is explicitly listed in
112
+ * `trustedHosts` (#254): an allowlisted cross-origin jwks_uri is accepted,
113
+ * must still be https (no OIDC_ALLOW_INSECURE_ISSUER or loopback exemption on
114
+ * the cross-origin path), and the acceptance is audit-logged (the one side
115
+ * effect of this function).
68
116
  */
69
- export function resolveJwksUri(discoveryDoc, issuer, allowInsecure = false) {
117
+ export function resolveJwksUri(discoveryDoc, issuer, allowInsecure = false, trustedHosts = []) {
70
118
  const issuerUrl = assertSecureIssuer(issuer, allowInsecure);
71
119
  if (!discoveryDoc || typeof discoveryDoc !== 'object') {
72
120
  throw new Error('OIDC discovery document is empty or not an object');
@@ -92,8 +140,34 @@ export function resolveJwksUri(discoveryDoc, issuer, allowInsecure = false) {
92
140
  // same host is a different service, so comparing origins keeps the guarantee the
93
141
  // security review asked for. Both are https here, so this is effectively host+port.
94
142
  if (jwks.origin.toLowerCase() !== issuerUrl.origin.toLowerCase()) {
95
- throw new Error(`OIDC "jwks_uri" origin (${jwks.origin}) does not match the issuer origin (${issuerUrl.origin}); ` +
96
- 'refusing a cross-origin key fetch. A cross-host issuer (e.g. Google) needs an explicit trusted-host allowlist (#245).');
143
+ // #254: opt-in escape hatch for legitimately cross-host IdPs (Google serves
144
+ // JWKS from www.googleapis.com). Matching is on URL.host (hostname PLUS any
145
+ // non-default port), exact and lowercased: a bare-host entry does not trust
146
+ // odd ports on that host, preserving this file's origin-level strictness.
147
+ // Empty allowlist (the default) makes this branch behave exactly as before.
148
+ const jwksHost = jwks.host.toLowerCase();
149
+ if (!trustedHosts.includes(jwksHost)) {
150
+ throw new Error(`OIDC "jwks_uri" origin (${jwks.origin}) does not match the issuer origin (${issuerUrl.origin}); ` +
151
+ `refusing a cross-origin key fetch. Checked host "${jwksHost}" against ` +
152
+ `OIDC_JWKS_TRUSTED_HOSTS [${trustedHosts.join(', ')}]; matching is exact on host:port ` +
153
+ '(#254). A cross-host issuer (e.g. Google) needs its JWKS host explicitly allowlisted.');
154
+ }
155
+ // An allowlisted CROSS-ORIGIN fetch must be https, with no exemption: not
156
+ // for OIDC_ALLOW_INSECURE_ISSUER (the #244 opt-out covers a same-origin LAN
157
+ // issuer, never third-party key hosts) and not for loopback (a shared-host
158
+ // process could bind the port). Without this, combining the two opt-ins
159
+ // would newly permit a plaintext cross-origin key fetch that pre-#254 code
160
+ // always refused.
161
+ if (jwks.protocol !== 'https:') {
162
+ throw new Error(`OIDC cross-origin "jwks_uri" allowed by OIDC_JWKS_TRUSTED_HOSTS must use https (got ${jwks.protocol}); ` +
163
+ 'the OIDC_ALLOW_INSECURE_ISSUER opt-out never extends to cross-origin key fetches (#254).');
164
+ }
165
+ // Audit trail: a normally-rejected cross-origin trust was relaxed by operator config.
166
+ logger.info('cross-origin JWKS host allowed by OIDC_JWKS_TRUSTED_HOSTS', {
167
+ issuerOrigin: issuerUrl.origin,
168
+ jwksHost,
169
+ jwksUri: jwks.toString(),
170
+ });
97
171
  }
98
172
  return jwks.toString();
99
173
  }
@@ -102,7 +176,7 @@ export function resolveJwksUri(discoveryDoc, issuer, allowInsecure = false) {
102
176
  * Fails closed on any problem (non-https issuer, redirect, non-200, bad JSON,
103
177
  * missing/invalid/cross-host jwks_uri). `fetchImpl` is injectable for testing.
104
178
  */
105
- export async function discoverJwksUri(issuer, allowInsecure = false, fetchImpl = fetch) {
179
+ export async function discoverJwksUri(issuer, allowInsecure = false, trustedHosts = [], fetchImpl = fetch) {
106
180
  assertSecureIssuer(issuer, allowInsecure);
107
181
  const base = issuer.endsWith('/') ? issuer.slice(0, -1) : issuer;
108
182
  const discoveryUrl = `${base}/.well-known/openid-configuration`;
@@ -126,7 +200,7 @@ export async function discoverJwksUri(issuer, allowInsecure = false, fetchImpl =
126
200
  catch {
127
201
  throw new Error(`OIDC discovery document at ${discoveryUrl} is not valid JSON`);
128
202
  }
129
- const jwksUri = resolveJwksUri(doc, issuer, allowInsecure);
203
+ const jwksUri = resolveJwksUri(doc, issuer, allowInsecure, trustedHosts);
130
204
  logger.info('Resolved JWKS URI from OIDC discovery', { jwksUri });
131
205
  return jwksUri;
132
206
  }
@@ -0,0 +1,48 @@
1
+ import config from '../config.js';
2
+ /**
3
+ * Bound a single upstream Actual API call so a stall cannot hold the process-global
4
+ * API mutex (or a session-open path) forever (#270). `@actual-app/api` is a
5
+ * single-connection singleton: every op is serialised through `withApiLock`, and
6
+ * session open runs `api.init()` + `downloadBudget()` in `ActualConnectionPool`.
7
+ * The mutex releases in a `.finally()`, but the upstream calls it guards
8
+ * (`api.init`, `downloadBudget`, `api.sync`, and each tool operation body) had no
9
+ * timeout. If one stalled (a half-open socket, the server restarting mid-request,
10
+ * a VPN idle-drop) the `release()` never ran and every subsequent tool call
11
+ * blocked forever.
12
+ *
13
+ * This races the call against `ACTUAL_OP_TIMEOUT_MS`. On timeout it REJECTS with a
14
+ * clear, actionable error; because the rejection propagates through the enclosing
15
+ * `withApiLock(...).finally(release)` (reads), the write-queue catch (writes), or
16
+ * the pool's own try/catch (session open), the resource is freed and later calls
17
+ * proceed. A configured timeout of 0 disables the bound.
18
+ *
19
+ * Recovery: in the adapter legacy path the enclosing `finally` runs
20
+ * `shutdownActualApi()`, so the next call re-inits a fresh singleton. In pooled
21
+ * mode the timeout error message ("timed out") matches the transient pattern list
22
+ * (retry.ts), so `_shouldDropPoolOnError` drops the pooled connection and the next
23
+ * call re-inits cleanly. Because the timeout is thrown OUTSIDE any `retry()`,
24
+ * classing it transient drops the pool without causing a retry storm.
25
+ *
26
+ * `fn()` is invoked via `Promise.resolve().then(fn)` so a SYNCHRONOUS throw from
27
+ * `fn` becomes a rejected promise the race can settle on, rather than escaping
28
+ * before `Promise.race` is built (which would orphan the armed timer and later
29
+ * fire an unhandledRejection).
30
+ */
31
+ export function withOpTimeout(fn, label = 'operation') {
32
+ const timeoutMs = config.ACTUAL_OP_TIMEOUT_MS;
33
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
34
+ return fn();
35
+ }
36
+ let timer;
37
+ const timeout = new Promise((_, reject) => {
38
+ timer = setTimeout(() => {
39
+ reject(new Error(`Actual API ${label} timed out after ${timeoutMs}ms (ACTUAL_OP_TIMEOUT_MS). ` +
40
+ 'The upstream Actual server may be unreachable or stalled; the operation was ' +
41
+ 'aborted so the session can recover. Please retry the request.'));
42
+ }, timeoutMs);
43
+ });
44
+ return Promise.race([Promise.resolve().then(fn), timeout]).finally(() => {
45
+ if (timer)
46
+ clearTimeout(timer);
47
+ });
48
+ }
@@ -20,6 +20,12 @@ export const TRANSIENT_ERROR_PATTERNS = [
20
20
  'ETIMEDOUT',
21
21
  'out of memory',
22
22
  'ENOMEM',
23
+ // #270: the adapter's per-op timeout (withOpTimeout) rejects a stalled upstream
24
+ // call with a message containing "timed out". Classing it transient means the
25
+ // pooled connection is dropped so the session re-inits cleanly on the next
26
+ // call. The timeout error is thrown OUTSIDE any retry(), so this does not cause
27
+ // a retry storm; it only feeds the pool-drop decision.
28
+ 'timed out',
23
29
  ];
24
30
  /**
25
31
  * True only for known transient/infrastructure errors (#177). Non-Error and
@@ -13,7 +13,7 @@ import config from '../config.js';
13
13
  import { createRemoteJWKSet, jwtVerify } from 'jose';
14
14
  import { MCPAuthTokenVerificationError } from 'mcp-auth';
15
15
  import { createMcpAuth } from '../auth/setup.js';
16
- import { discoverJwksUri } from '../lib/oidc-discovery.js';
16
+ import { discoverJwksUri, buildTrustedJwksHosts } from '../lib/oidc-discovery.js';
17
17
  import { buildAcceptedAudiences } from '../lib/oidc-audiences.js';
18
18
  import { budgetAclMiddleware } from '../auth/budget-acl.js';
19
19
  import * as https from 'node:https';
@@ -83,7 +83,10 @@ advertisedUrl) {
83
83
  // closed on a non-https issuer, a cross-origin redirect, a missing/invalid
84
84
  // jwks_uri, or a jwks_uri on a different host than the issuer. jose's
85
85
  // createRemoteJWKSet then fetches the keys lazily and caches them.
86
- const jwksUri = await discoverJwksUri(config.OIDC_ISSUER, config.OIDC_ALLOW_INSECURE_ISSUER);
86
+ // #254: opt-in cross-origin JWKS trusted hosts (e.g. Google serves keys from
87
+ // www.googleapis.com). Parsed fail-fast here at the composition root and
88
+ // threaded as a parameter; empty (the default) keeps same-origin-only.
89
+ const jwksUri = await discoverJwksUri(config.OIDC_ISSUER, config.OIDC_ALLOW_INSECURE_ISSUER, buildTrustedJwksHosts(config.OIDC_JWKS_TRUSTED_HOSTS));
87
90
  const jwks = createRemoteJWKSet(new URL(jwksUri));
88
91
  const customJwtVerify = async (token) => {
89
92
  // Enforce the audience claim (#160, OWASP A07). Without it, any
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "actual-mcp-server",
3
3
  "displayName": "Actual MCP Server",
4
- "version": "0.7.13",
4
+ "version": "0.8.3",
5
5
  "engines": {
6
6
  "node": ">=22.0.0",
7
7
  "npm": ">=10.0.0"
@@ -30,7 +30,7 @@
30
30
  "verify-tools": "npm run build && node scripts/verify-tools.js",
31
31
  "check:coverage": "node scripts/list-actual-api-methods.mjs",
32
32
  "direct-sync": "node scripts/direct-sync/bank-sync-direct.mjs",
33
- "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js",
33
+ "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js",
34
34
  "test:adapter": "npm run build && node dist/src/tests_adapter_runner.js",
35
35
  "test:e2e": "npx playwright test",
36
36
  "test:e2e:docker": "./tests/e2e/run-docker-e2e.sh",
@@ -78,7 +78,7 @@
78
78
  "devDependencies": {
79
79
  "@playwright/test": "^1.60.0",
80
80
  "@types/express": "^5.0.3",
81
- "@types/node": "^25.9.2",
81
+ "@types/node": "^26.1.0",
82
82
  "knip": "^6.16.1",
83
83
  "node-fetch": "^3.3.2",
84
84
  "tsconfig-paths": "^4.2.0",