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 +3 -1
- package/dist/package.json +3 -3
- package/dist/src/config.js +29 -0
- package/dist/src/lib/ActualConnectionPool.js +21 -12
- package/dist/src/lib/actual-adapter.js +36 -15
- package/dist/src/lib/oidc-discovery.js +87 -13
- package/dist/src/lib/opTimeout.js +48 -0
- package/dist/src/lib/retry.js +6 -0
- package/dist/src/server/httpServer.js +5 -2
- package/package.json +3 -3
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.
|
|
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.
|
|
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": "^
|
|
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",
|
package/dist/src/config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
18
|
-
*
|
|
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
|
|
21
|
-
* network
|
|
22
|
-
*
|
|
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
|
-
*
|
|
66
|
-
* against the issuer. Throws (fail closed) if it is missing, not
|
|
67
|
-
* embedded credentials, or is not same-origin (scheme + host +
|
|
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
|
-
|
|
96
|
-
|
|
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
|
+
}
|
package/dist/src/lib/retry.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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": "^
|
|
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",
|