actual-mcp-server 0.22.46 → 0.22.49
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 +1 -1
- package/dist/src/actualConnection.js +8 -8
- package/dist/src/auth/budget-acl-dynamic.js +4 -1
- package/dist/src/lib/ActualConnectionPool.js +6 -12
- package/dist/src/lib/actual-adapter.js +169 -40
- package/dist/src/lib/apiLogin.js +75 -0
- package/dist/src/lib/apiState.js +28 -1
- package/dist/src/server/httpServer.js +10 -2
- package/dist/src/tools/budgets_get_all.js +1 -1
- package/dist/src/tools/budgets_import.js +7 -8
- package/dist/src/tools/budgets_list_available.js +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -836,7 +836,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
|
|
|
836
836
|
|
|
837
837
|
---
|
|
838
838
|
|
|
839
|
-
**Version:** 0.22.
|
|
839
|
+
**Version:** 0.22.49 | **Tool Count:** 84 (verified LibreChat-compatible)
|
|
840
840
|
|
|
841
841
|
## Sponsor
|
|
842
842
|
|
|
@@ -4,7 +4,7 @@ import os from 'os';
|
|
|
4
4
|
import api from '@actual-app/api';
|
|
5
5
|
import { loadBudgetTracked } from './lib/budgetLoader.js';
|
|
6
6
|
import { withApiLock } from './lib/apiLock.js';
|
|
7
|
-
import {
|
|
7
|
+
import { initApiLoggedIn } from './lib/apiLogin.js';
|
|
8
8
|
import logger from './logger.js';
|
|
9
9
|
import config from './config.js';
|
|
10
10
|
import { connectionPool } from './lib/ActualConnectionPool.js';
|
|
@@ -69,11 +69,7 @@ export async function connectToActual() {
|
|
|
69
69
|
// settle, `.finally(release)` would never run, and the process-global api mutex would be
|
|
70
70
|
// wedged permanently for every session: the exact #270 class. Both pool sites already bound
|
|
71
71
|
// their `api.init` this way, and this was the only `api.init` in the repo that did not.
|
|
72
|
-
await
|
|
73
|
-
dataDir: DATA_DIR,
|
|
74
|
-
serverURL: SERVER_URL,
|
|
75
|
-
password: PASSWORD,
|
|
76
|
-
}), 'connectToActual init');
|
|
72
|
+
await initApiLoggedIn(DATA_DIR, { serverUrl: SERVER_URL, password: PASSWORD || '' }, 'connectToActual init');
|
|
77
73
|
log.info(`Downloading budget with sync ID: ${BUDGET_SYNC_ID}`);
|
|
78
74
|
await loadBudgetTracked(BUDGET_SYNC_ID, BUDGET_PASSWORD);
|
|
79
75
|
});
|
|
@@ -132,8 +128,12 @@ export async function shutdownActual() {
|
|
|
132
128
|
/**
|
|
133
129
|
* Initialize connection for a specific MCP session
|
|
134
130
|
* Uses connection pooling to give each session its own Actual Budget connection
|
|
131
|
+
*
|
|
132
|
+
* #571: `budget` is the budget the session opens on (httpServer resolves it with
|
|
133
|
+
* initialBudgetForSession). Without it the pool logs in to the default server and downloads the
|
|
134
|
+
* default budget, which is wrong whenever the session's restored budget lives on another server.
|
|
135
135
|
*/
|
|
136
|
-
export async function connectToActualForSession(sessionId) {
|
|
136
|
+
export async function connectToActualForSession(sessionId, budget) {
|
|
137
137
|
if (!useConnectionPool) {
|
|
138
138
|
// Fallback to shared connection
|
|
139
139
|
return connectToActual();
|
|
@@ -141,7 +141,7 @@ export async function connectToActualForSession(sessionId) {
|
|
|
141
141
|
try {
|
|
142
142
|
// Ensure connection pool initialization is complete before accepting connections
|
|
143
143
|
await connectionPool.waitForInitialization();
|
|
144
|
-
await connectionPool.getConnection(sessionId);
|
|
144
|
+
await connectionPool.getConnection(sessionId, budget);
|
|
145
145
|
logger.info(`Actual API connection ready for session: ${sessionId}`);
|
|
146
146
|
}
|
|
147
147
|
catch (err) {
|
|
@@ -40,7 +40,10 @@
|
|
|
40
40
|
// point runs `_enforceBudgetAcl`, which refuses when the request context has no
|
|
41
41
|
// allowedBudgets. Resolving the ACL would therefore be blocked by the ACL it is
|
|
42
42
|
// trying to compute. We break that circularity by running ONLY this one call
|
|
43
|
-
// inside `requestContext.run({ allowedBudgets: ['*'] }, ...)`.
|
|
43
|
+
// inside `requestContext.run({ allowedBudgets: ['*'] }, ...)`. Since #570 the
|
|
44
|
+
// wildcard matters twice: `getBudgets()` also FILTERS its result to the context's
|
|
45
|
+
// allowedBudgets, so without it the resolver would see only the files the
|
|
46
|
+
// caller's (not yet computed) list already permits.
|
|
44
47
|
//
|
|
45
48
|
// That is sound rather than a loophole: `getBudgets()` lists budget FILES
|
|
46
49
|
// (name, id, who may access them). It reads no budget CONTENTS: no accounts, no
|
|
@@ -17,9 +17,9 @@ 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 { initApiLoggedIn } from './apiLogin.js';
|
|
20
21
|
import { withApiLock, isApiLockHeld } from './apiLock.js';
|
|
21
22
|
import { loadBudgetTracked } from './budgetLoader.js';
|
|
22
|
-
import { withOpTimeout } from './opTimeout.js';
|
|
23
23
|
const DEFAULT_DATA_DIR = path.resolve(os.homedir() || '.', '.actual');
|
|
24
24
|
class ActualConnectionPool {
|
|
25
25
|
connections = new Map();
|
|
@@ -297,7 +297,9 @@ class ActualConnectionPool {
|
|
|
297
297
|
const SERVER_URL = budgetOverride?.serverUrl ?? config.ACTUAL_SERVER_URL;
|
|
298
298
|
const PASSWORD = budgetOverride?.password ?? config.ACTUAL_PASSWORD;
|
|
299
299
|
const BUDGET_SYNC_ID = budgetOverride?.syncId ?? config.ACTUAL_BUDGET_SYNC_ID;
|
|
300
|
-
|
|
300
|
+
// #571: an override carries its own budget's encryption password, absent included. Falling back
|
|
301
|
+
// to the default budget's would bind a budget from another server to a password it never had.
|
|
302
|
+
const BUDGET_PASSWORD = budgetOverride ? budgetOverride.encryptionPassword : process.env.ACTUAL_BUDGET_PASSWORD;
|
|
301
303
|
// Use shared data directory so changes persist across sessions
|
|
302
304
|
// This is critical: all sessions must share the same database to avoid data loss
|
|
303
305
|
const DATA_DIR = config.MCP_BRIDGE_DATA_DIR || DEFAULT_DATA_DIR;
|
|
@@ -322,11 +324,7 @@ class ActualConnectionPool {
|
|
|
322
324
|
// Bound the session-open init/download (#270): this is the HTTP exposure.
|
|
323
325
|
// A stalled upstream here would otherwise hang session open unbounded and,
|
|
324
326
|
// because the api singleton is process-global, wedge other sessions too.
|
|
325
|
-
await
|
|
326
|
-
dataDir: DATA_DIR,
|
|
327
|
-
serverURL: SERVER_URL,
|
|
328
|
-
password: PASSWORD,
|
|
329
|
-
}), 'pool init');
|
|
327
|
+
await initApiLoggedIn(DATA_DIR, { serverUrl: SERVER_URL, password: PASSWORD || '' }, 'pool init');
|
|
330
328
|
logger.info(`[ConnectionPool] Downloading budget for session: ${sessionId}`);
|
|
331
329
|
// #390: the shared tracked loader, not a hand-written download. It clears the record
|
|
332
330
|
// BEFORE starting and keeps an abandoned load registered, so a session-open download that
|
|
@@ -409,11 +407,7 @@ class ActualConnectionPool {
|
|
|
409
407
|
// process-global singleton, so it takes the same mutex.
|
|
410
408
|
await withApiLock(async () => {
|
|
411
409
|
// Bound the session-open init/download (#270), same as getConnection.
|
|
412
|
-
await
|
|
413
|
-
dataDir: DATA_DIR,
|
|
414
|
-
serverURL: SERVER_URL,
|
|
415
|
-
password: PASSWORD,
|
|
416
|
-
}), 'pool init');
|
|
410
|
+
await initApiLoggedIn(DATA_DIR, { serverUrl: SERVER_URL, password: PASSWORD || '' }, 'pool init');
|
|
417
411
|
// #390: the shared tracked loader, not a hand-written download. It clears the record
|
|
418
412
|
// BEFORE starting and keeps an abandoned load registered, so a session-open download that
|
|
419
413
|
// outruns ACTUAL_OP_TIMEOUT_MS cannot silently re-point the singleton later.
|
|
@@ -23,7 +23,8 @@ import { parseBudgetRegistry } from './budget-registry.js';
|
|
|
23
23
|
import { getPreferredBudgetSyncId, setPreferredBudgetSyncId, pickAllowedPreferredBudget } from './budget-preference-store.js';
|
|
24
24
|
import { requestContext } from './requestContext.js';
|
|
25
25
|
import { connectionPool } from './ActualConnectionPool.js';
|
|
26
|
-
import { isApiInitialized, setApiInitialized, getLoadedBudgetSyncId, awaitAbandonedBudgetLoad, hasPendingBudgetLoad } from './apiState.js';
|
|
26
|
+
import { isApiInitialized, setApiInitialized, getLoadedBudgetSyncId, awaitAbandonedBudgetLoad, hasPendingBudgetLoad, isApiLoggedInTo, setLoginsSpanServers } from './apiState.js';
|
|
27
|
+
import { initApiLoggedIn } from './apiLogin.js';
|
|
27
28
|
import { withApiLock } from './apiLock.js';
|
|
28
29
|
import { loadBudgetTracked, importBudgetTracked } from './budgetLoader.js';
|
|
29
30
|
/**
|
|
@@ -36,6 +37,7 @@ const budgetRegistry = parseBudgetRegistry(process.env, {
|
|
|
36
37
|
syncId: config.ACTUAL_BUDGET_SYNC_ID,
|
|
37
38
|
encryptionPassword: config.ACTUAL_BUDGET_PASSWORD,
|
|
38
39
|
});
|
|
40
|
+
setLoginsSpanServers(new Set([...budgetRegistry.values()].map((b) => JSON.stringify([b.serverUrl, b.password || '']))).size > 1);
|
|
39
41
|
logger.info(`[ADAPTER] Budget registry: ${budgetRegistry.size} budget(s) — ` +
|
|
40
42
|
[...budgetRegistry.values()].map(b => `"${b.name}" (${b.serverUrl})`).join(', '));
|
|
41
43
|
/**
|
|
@@ -108,6 +110,25 @@ function getActiveBudgetConfig() {
|
|
|
108
110
|
}
|
|
109
111
|
return [...budgetRegistry.values()][0];
|
|
110
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* #571: the budget a NEW session should open on, resolved before its session id is live.
|
|
115
|
+
*
|
|
116
|
+
* httpServer opens the pool connection from `onsessioninitialized`, which runs inside the
|
|
117
|
+
* initialize request's context: principal and ACL are there, the session id is not yet. Without
|
|
118
|
+
* the id, getActiveBudgetConfig cannot restore the #189 preference, so the connection logged in
|
|
119
|
+
* to the DEFAULT server and downloaded the default budget even when the session's budget lives
|
|
120
|
+
* on another server, and the session's first call then asked the default server for it.
|
|
121
|
+
*
|
|
122
|
+
* Runs getActiveBudgetConfig with the id added to the CURRENT context, so the ACL and principal
|
|
123
|
+
* of the initialize request still decide. Its memoisation goes through setSessionBudget, which
|
|
124
|
+
* the isSessionLive guard keeps from recording a session that already ended.
|
|
125
|
+
*
|
|
126
|
+
* Shape credit: @jospaquim (PR #569).
|
|
127
|
+
*/
|
|
128
|
+
export function initialBudgetForSession(sessionId) {
|
|
129
|
+
const store = requestContext.getStore() ?? {};
|
|
130
|
+
return requestContext.run({ ...store, sessionId }, () => getActiveBudgetConfig());
|
|
131
|
+
}
|
|
111
132
|
/**
|
|
112
133
|
* Global API session mutex.
|
|
113
134
|
* @actual-app/api is a singleton with a single SQLite connection — concurrent
|
|
@@ -172,7 +193,10 @@ async function ensureLoadedBudgetMatchesSession() {
|
|
|
172
193
|
}
|
|
173
194
|
const wanted = getActiveBudgetConfig();
|
|
174
195
|
const loaded = getLoadedBudgetSyncId();
|
|
175
|
-
|
|
196
|
+
// #571: the sync id alone is not a match. The same sync id can exist on two servers (a budget
|
|
197
|
+
// copied between them), and a match against the wrong server would run this session's
|
|
198
|
+
// operation on the other server's copy.
|
|
199
|
+
if (loaded === wanted.syncId && isApiLoggedInTo(loginOf(wanted)))
|
|
176
200
|
return;
|
|
177
201
|
// Deliberately a WARN: reaching here means two sessions are on different budgets and this
|
|
178
202
|
// one would otherwise have operated on the other's data.
|
|
@@ -185,7 +209,11 @@ async function ensureLoadedBudgetMatchesSession() {
|
|
|
185
209
|
// per operation, a drain can now change budgets MID-BATCH, and the single trailing api.sync()
|
|
186
210
|
// covers only the last one. Without this, an earlier session's write is applied locally and
|
|
187
211
|
// silently waits for the next load of that budget to propagate, which an ephemeral data dir
|
|
188
|
-
// would lose entirely.
|
|
212
|
+
// would lose entirely. Runs on a cross-login re-selection too, while the old login is still
|
|
213
|
+
// current: initApiLoggedIn's close deliberately does not sync (see there for the out-of-sync
|
|
214
|
+
// failure a syncing close caused at session open), so this is the only sync the outgoing budget
|
|
215
|
+
// gets. It is still a sync of a copy that may be stale if another process wrote the data dir:
|
|
216
|
+
// #575 tracks that hazard, which every sync here and after writes shares.
|
|
189
217
|
if (loaded) {
|
|
190
218
|
try {
|
|
191
219
|
await withOpTimeout(() => api.sync(), 'sync');
|
|
@@ -197,7 +225,45 @@ async function ensureLoadedBudgetMatchesSession() {
|
|
|
197
225
|
});
|
|
198
226
|
}
|
|
199
227
|
}
|
|
200
|
-
await
|
|
228
|
+
await loadBudgetOnItsServer(wanted);
|
|
229
|
+
}
|
|
230
|
+
function loginOf(budget) {
|
|
231
|
+
return { serverUrl: budget.serverUrl, password: budget.password || '' };
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* #571: load a budget over a login to the server that HOLDS it. MUST be called inside the api lock.
|
|
235
|
+
*
|
|
236
|
+
* Every re-selection over a live singleton goes through here (the #390 precondition, which the
|
|
237
|
+
* kept-alive stdio singleton of #419 reaches too, and switchBudget's same-auth fast path). Both
|
|
238
|
+
* used to download over whatever login the singleton already had, which is right on a single
|
|
239
|
+
* server and wrong as soon as another session has logged the singleton in to a different one:
|
|
240
|
+
* reproduced live as a session on server X failing every call with "Budget not found" for as
|
|
241
|
+
* long as a session on server Y was active.
|
|
242
|
+
*
|
|
243
|
+
* Same server: no new login, so the #127 auth burst #134 removed does not come back.
|
|
244
|
+
*
|
|
245
|
+
* Different or unknown server: log in to the budget's own server with the budget's own password
|
|
246
|
+
* through initApiLoggedIn, which first clears the records and closes the outgoing budget WITHOUT
|
|
247
|
+
* syncing it, then download. The #390 precondition syncs the outgoing budget before calling here;
|
|
248
|
+
* switchBudget's same-auth fast path does not, so a pending change there syncs at that budget's
|
|
249
|
+
* next load.
|
|
250
|
+
*
|
|
251
|
+
* Failure: initApiLoggedIn clears the loaded-budget and login records before anything else, so
|
|
252
|
+
* any failure leaves both unknown, the next operation of ANY session re-selects, and the error
|
|
253
|
+
* reaches the caller as an ordinary tool error. The live flag is deliberately NOT cleared:
|
|
254
|
+
* clearing it would send every session through the legacy per-operation init (the #127 burst)
|
|
255
|
+
* until a new session happened to open, while the cleared records already force the one re-login
|
|
256
|
+
* that recovery needs.
|
|
257
|
+
*
|
|
258
|
+
* Never takes the api lock itself: the lock is not reentrant and every caller already holds it.
|
|
259
|
+
*/
|
|
260
|
+
async function loadBudgetOnItsServer(budget) {
|
|
261
|
+
const login = loginOf(budget);
|
|
262
|
+
if (!isApiLoggedInTo(login)) {
|
|
263
|
+
logger.info(`[ADAPTER] logging the api in to ${budget.serverUrl} before loading "${budget.name}"`);
|
|
264
|
+
await withAuthRetry(() => initApiLoggedIn(config.MCP_BRIDGE_DATA_DIR, login, 'init'));
|
|
265
|
+
}
|
|
266
|
+
await loadBudgetTracked(budget.syncId, budget.encryptionPassword);
|
|
201
267
|
}
|
|
202
268
|
// Per-op timeout (#270) lives in ./opTimeout.ts so ActualConnectionPool can bound
|
|
203
269
|
// its own session-open init/download without a circular import back into this
|
|
@@ -379,9 +445,7 @@ function abortBatchOnInfrastructureError(error, ctx) {
|
|
|
379
445
|
* Claude Desktop / Claude Code compatibility.
|
|
380
446
|
*/
|
|
381
447
|
function _enforceBudgetAcl(toolName) {
|
|
382
|
-
const
|
|
383
|
-
const sessionId = store?.sessionId;
|
|
384
|
-
const allowedBudgets = store?.allowedBudgets;
|
|
448
|
+
const sessionId = requestContext.getStore()?.sessionId;
|
|
385
449
|
// Trusted-local short-circuit. stdio and startup health checks run with no
|
|
386
450
|
// sessionId; in non-OIDC modes those are by-construction trusted (single
|
|
387
451
|
// user, local process). The ACL only applies when an authenticated multi-
|
|
@@ -389,20 +453,8 @@ function _enforceBudgetAcl(toolName) {
|
|
|
389
453
|
if (!sessionId && config.AUTH_PROVIDER !== 'oidc') {
|
|
390
454
|
return;
|
|
391
455
|
}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
if (config.AUTH_PROVIDER === 'oidc' && !allowedBudgets) {
|
|
395
|
-
logger.warn(JSON.stringify({
|
|
396
|
-
event: 'acl_denied',
|
|
397
|
-
reason: 'no_allowed_budgets_in_context',
|
|
398
|
-
sessionId: sessionId ?? null,
|
|
399
|
-
tool: toolName ?? null,
|
|
400
|
-
}));
|
|
401
|
-
throw new Error('Budget ACL: no allowedBudgets in request context. ' +
|
|
402
|
-
'This request bypassed the budget-acl middleware. Refusing for safety. See #156.');
|
|
403
|
-
}
|
|
404
|
-
// No restriction.
|
|
405
|
-
if (!allowedBudgets || allowedBudgets.includes('*'))
|
|
456
|
+
const allowedBudgets = restrictedAllowedBudgets(toolName);
|
|
457
|
+
if (!allowedBudgets)
|
|
406
458
|
return;
|
|
407
459
|
const target = getActiveBudgetConfig();
|
|
408
460
|
if (!allowedBudgets.includes(target.syncId)) {
|
|
@@ -414,8 +466,45 @@ function _enforceBudgetAcl(toolName) {
|
|
|
414
466
|
sessionId: sessionId ?? null,
|
|
415
467
|
tool: toolName ?? null,
|
|
416
468
|
}));
|
|
417
|
-
|
|
469
|
+
// #570: the name and syncId stay in the log above, never in the caller-facing
|
|
470
|
+
// text. With no selection the active budget is the registry's first entry, so
|
|
471
|
+
// naming it told a principal allowed only another budget what the default was.
|
|
472
|
+
throw new Error("Budget ACL: the active budget is not in this session's allowedBudgets. " +
|
|
473
|
+
'Call actual_budgets_list_available and switch to a permitted budget.');
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* The request's budget allow-list when the caller is RESTRICTED, else `undefined` (#570).
|
|
478
|
+
*
|
|
479
|
+
* Shared by `_enforceBudgetAcl` (the active budget) and the paths that check nothing
|
|
480
|
+
* about the active budget: the two listings, the import and the switch's not-found
|
|
481
|
+
* list. One decision, so the two kinds of check cannot drift apart.
|
|
482
|
+
*
|
|
483
|
+
* - `AUTH_PROVIDER=oidc` with no `allowedBudgets` in context: the request slipped
|
|
484
|
+
* past the middleware, so throw (fail closed), with an `acl_denied` warn.
|
|
485
|
+
* - a list containing `"*"`, or no list outside OIDC (bearer, no-auth, stdio, CLI,
|
|
486
|
+
* startup): unrestricted.
|
|
487
|
+
* - otherwise: the list.
|
|
488
|
+
*
|
|
489
|
+
* Keyed on the request's allow-list, not on AUTH_PROVIDER, so any auth mode that
|
|
490
|
+
* attaches a list is restricted by it.
|
|
491
|
+
*/
|
|
492
|
+
function restrictedAllowedBudgets(toolName) {
|
|
493
|
+
const store = requestContext.getStore();
|
|
494
|
+
const allowedBudgets = store?.allowedBudgets;
|
|
495
|
+
if (config.AUTH_PROVIDER === 'oidc' && !allowedBudgets) {
|
|
496
|
+
logger.warn(JSON.stringify({
|
|
497
|
+
event: 'acl_denied',
|
|
498
|
+
reason: 'no_allowed_budgets_in_context',
|
|
499
|
+
sessionId: store?.sessionId ?? null,
|
|
500
|
+
tool: toolName ?? null,
|
|
501
|
+
}));
|
|
502
|
+
throw new Error('Budget ACL: no allowedBudgets in request context. ' +
|
|
503
|
+
'This request bypassed the budget-acl middleware. Refusing for safety. See #156.');
|
|
418
504
|
}
|
|
505
|
+
if (!allowedBudgets || allowedBudgets.includes('*'))
|
|
506
|
+
return undefined;
|
|
507
|
+
return allowedBudgets;
|
|
419
508
|
}
|
|
420
509
|
/**
|
|
421
510
|
* Helper to run an operation with the Actual API ready, deciding the lifecycle
|
|
@@ -718,11 +807,7 @@ async function initActualApiForOperation() {
|
|
|
718
807
|
// withAuthRetry does not retry it. Wrapping per-attempt (not the whole retry
|
|
719
808
|
// loop) means legitimate #127 auth-rate-limit backoff (up to ~25s) is not
|
|
720
809
|
// counted against ACTUAL_OP_TIMEOUT_MS.
|
|
721
|
-
await withAuthRetry(() =>
|
|
722
|
-
dataDir: DATA_DIR,
|
|
723
|
-
serverURL: budget.serverUrl,
|
|
724
|
-
password: budget.password || '',
|
|
725
|
-
}), 'init'));
|
|
810
|
+
await withAuthRetry(() => initApiLoggedIn(DATA_DIR, loginOf(budget), 'init'));
|
|
726
811
|
logger.debug('[ADAPTER] Downloading budget');
|
|
727
812
|
// Bound downloadBudget (#270): the legacy stdio path re-downloads on every
|
|
728
813
|
// op, and a stall here was the production hang. On timeout it rejects and
|
|
@@ -3862,7 +3947,18 @@ export async function getBudgets() {
|
|
|
3862
3947
|
return withActualApi(async () => {
|
|
3863
3948
|
observability.incrementToolCall('actual.budgets.getAll').catch(() => { });
|
|
3864
3949
|
const raw = await withConcurrency(() => retry(() => rawGetBudgets(), { retries: 2, backoffMs: 200 }));
|
|
3865
|
-
|
|
3950
|
+
const files = Array.isArray(raw) ? raw : [];
|
|
3951
|
+
// #570: a restricted caller sees only files whose groupId (the sync id) it may
|
|
3952
|
+
// use. A file with no groupId was never uploaded and belongs to no list. The
|
|
3953
|
+
// dynamic ACL resolver calls this under its own `allowedBudgets: ['*']`, so it
|
|
3954
|
+
// still sees every file.
|
|
3955
|
+
const allowed = restrictedAllowedBudgets('actual_budgets_get_all');
|
|
3956
|
+
if (!allowed)
|
|
3957
|
+
return files;
|
|
3958
|
+
return files.filter(f => {
|
|
3959
|
+
const groupId = f?.groupId;
|
|
3960
|
+
return typeof groupId === 'string' && allowed.includes(groupId);
|
|
3961
|
+
});
|
|
3866
3962
|
});
|
|
3867
3963
|
}
|
|
3868
3964
|
/**
|
|
@@ -3899,12 +3995,18 @@ export async function switchBudget(name) {
|
|
|
3899
3995
|
}
|
|
3900
3996
|
const key = name.toLowerCase();
|
|
3901
3997
|
const found = budgetRegistry.get(key);
|
|
3902
|
-
|
|
3903
|
-
|
|
3904
|
-
|
|
3998
|
+
// #570: a restricted caller is told only about budgets it may use.
|
|
3999
|
+
const notFound = () => {
|
|
4000
|
+
const permitted = restrictedAllowedBudgets('actual_budgets_switch');
|
|
4001
|
+
const available = [...budgetRegistry.values()]
|
|
4002
|
+
.filter(b => !permitted || permitted.includes(b.syncId))
|
|
4003
|
+
.map(b => `"${b.name}"`).join(', ') || 'none permitted for this session';
|
|
4004
|
+
return new Error(`Budget "${name}" not found in configuration. ` +
|
|
3905
4005
|
`Available budgets: ${available}. ` +
|
|
3906
4006
|
`Add BUDGET_n_NAME/SYNC_ID/SERVER_URL vars to configure additional budgets.`);
|
|
3907
|
-
}
|
|
4007
|
+
};
|
|
4008
|
+
if (!found)
|
|
4009
|
+
throw notFound();
|
|
3908
4010
|
// ACL enforcement: the target budget must be in this session's allowedBudgets.
|
|
3909
4011
|
// OIDC mode: explicit ACL required. Non-OIDC: short-circuit allow (single-user).
|
|
3910
4012
|
if (config.AUTH_PROVIDER === 'oidc') {
|
|
@@ -3926,7 +4028,12 @@ export async function switchBudget(name) {
|
|
|
3926
4028
|
sessionId,
|
|
3927
4029
|
tool: 'actual_budgets_switch',
|
|
3928
4030
|
}));
|
|
3929
|
-
|
|
4031
|
+
// #570: a denied budget gets the SAME error as an unknown name, so guessing
|
|
4032
|
+
// names cannot confirm that a budget outside the list exists. The round 1
|
|
4033
|
+
// review accepted a distinct "Budget ACL:" refusal here; that was reversed
|
|
4034
|
+
// because hiding other budgets is the point of this ticket. The log above
|
|
4035
|
+
// keeps the syncId for the operator.
|
|
4036
|
+
throw notFound();
|
|
3930
4037
|
}
|
|
3931
4038
|
}
|
|
3932
4039
|
// #189 Phase 1: the principal's chosen budget is persisted at each commit
|
|
@@ -3960,8 +4067,10 @@ export async function switchBudget(name) {
|
|
|
3960
4067
|
}
|
|
3961
4068
|
else {
|
|
3962
4069
|
await withApiLock(async () => {
|
|
3963
|
-
// Bound the in-place budget reload too (#270).
|
|
3964
|
-
|
|
4070
|
+
// Bound the in-place budget reload too (#270). #571: "same auth" compares against THIS
|
|
4071
|
+
// session's pool entry, but the singleton may be logged in to another session's server by
|
|
4072
|
+
// now, so the load still has to make sure it is talking to this budget's server.
|
|
4073
|
+
await loadBudgetOnItsServer(found);
|
|
3965
4074
|
});
|
|
3966
4075
|
}
|
|
3967
4076
|
connectionPool.updateLoadedSyncId(sessionId, found.syncId);
|
|
@@ -4033,10 +4142,13 @@ export function clearSessionBudgetState(sessionId) {
|
|
|
4033
4142
|
sessionBudgetState.delete(sessionId);
|
|
4034
4143
|
}
|
|
4035
4144
|
/**
|
|
4036
|
-
* Return
|
|
4145
|
+
* Return the configured budgets this caller may use (for actual_budgets_list_available).
|
|
4146
|
+
* A restricted caller sees only its permitted entries (#570); the startup registry log
|
|
4147
|
+
* reads the Map directly and is not filtered.
|
|
4037
4148
|
*/
|
|
4038
4149
|
export function getBudgetRegistry() {
|
|
4039
|
-
|
|
4150
|
+
const allowed = restrictedAllowedBudgets('actual_budgets_list_available');
|
|
4151
|
+
return [...budgetRegistry.values()].filter(b => !allowed || allowed.includes(b.syncId)).map(b => ({
|
|
4040
4152
|
name: b.name,
|
|
4041
4153
|
syncId: b.syncId,
|
|
4042
4154
|
serverUrl: b.serverUrl,
|
|
@@ -4237,11 +4349,28 @@ export async function exportBudget() {
|
|
|
4237
4349
|
* Side effect worth knowing at every call site: upstream `importBudget` LOADS the
|
|
4238
4350
|
* imported budget, so the session's active budget changes to the new file. It is
|
|
4239
4351
|
* therefore outside the `BUDGET_N_*` registry and outside whatever budget the ACL
|
|
4240
|
-
* resolved for this request. The
|
|
4241
|
-
*
|
|
4242
|
-
*
|
|
4352
|
+
* resolved for this request. The resulting budget is un-ACL'd because it did not
|
|
4353
|
+
* exist when the ACL was built, so a RESTRICTED caller is refused outright (#570),
|
|
4354
|
+
* before any lock or upstream call. Wildcard and unrestricted callers can still
|
|
4355
|
+
* import; the write queue also enforces the ACL on their active budget.
|
|
4356
|
+
*
|
|
4357
|
+
* Upstream creates a NEW cloud file for the import (fresh id, best-effort upload),
|
|
4358
|
+
* so it never overwrites another budget on the server. The local copy is keyed by
|
|
4359
|
+
* the archive's own id, so an archive of budget X replaces X's LOCAL copy.
|
|
4243
4360
|
*/
|
|
4244
4361
|
export async function importBudget(input, opts = {}) {
|
|
4362
|
+
const restricted = restrictedAllowedBudgets('actual_budgets_import');
|
|
4363
|
+
if (restricted) {
|
|
4364
|
+
logger.warn(JSON.stringify({
|
|
4365
|
+
event: 'acl_denied',
|
|
4366
|
+
reason: 'import_by_restricted_principal',
|
|
4367
|
+
allowedBudgets: restricted,
|
|
4368
|
+
sessionId: requestContext.getStore()?.sessionId ?? null,
|
|
4369
|
+
tool: 'actual_budgets_import',
|
|
4370
|
+
}));
|
|
4371
|
+
throw new Error('Budget ACL: actual_budgets_import is not available to a session with a restricted ' +
|
|
4372
|
+
'allowedBudgets list: the imported budget would belong to no permitted list.');
|
|
4373
|
+
}
|
|
4245
4374
|
observability.incrementToolCall('actual.budgets.import').catch(() => { });
|
|
4246
4375
|
const result = await queueWriteOperation(async () => {
|
|
4247
4376
|
// #394: an import re-points the process-global singleton exactly as a download does, so it
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import api from '@actual-app/api';
|
|
2
|
+
import { withOpTimeout } from './opTimeout.js';
|
|
3
|
+
import { setApiLogin, setLoadedBudgetSyncId, registerBudgetLoad, loginsSpanServers } from './apiState.js';
|
|
4
|
+
// Upstream's `init` returns its module-level handler object, the same one every time. Upstream
|
|
5
|
+
// exports no other way to reach `close-budget`. Nothing can be open before the first init, which
|
|
6
|
+
// is when this is still unset.
|
|
7
|
+
let upstreamLib = null;
|
|
8
|
+
/**
|
|
9
|
+
* #571: the ONLY place in src/ that calls `api.init`.
|
|
10
|
+
*
|
|
11
|
+
* With a multi-server budget registry, whether the singleton may skip a login depends on
|
|
12
|
+
* knowing which server (and password) it is logged in to, and an unknown login counts as a
|
|
13
|
+
* mismatch. A site that called `api.init` without recording the login would therefore not be
|
|
14
|
+
* wrong, only slow: every later operation would log in again, which is the #127 auth burst that
|
|
15
|
+
* #134 removed. Routing every init through here makes "logged in" and "recorded" one step, and
|
|
16
|
+
* `tests/unit/multi_server_budget_selection.test.js` fails on any raw `api.init(` elsewhere.
|
|
17
|
+
*
|
|
18
|
+
* Order, every time:
|
|
19
|
+
* 1. clear the login and loaded-budget records. Upstream's init points the client at the new
|
|
20
|
+
* server before it signs in, so a login that throws or times out leaves the client on a
|
|
21
|
+
* server with nothing proven; clearing first means nothing trusts it afterwards, and the
|
|
22
|
+
* next operation of any session re-selects.
|
|
23
|
+
* 2. close any open budget with upstream's `close-budget`, which does NOT sync. Upstream's init
|
|
24
|
+
* does not close an open budget, so without this a pending change of the outgoing budget
|
|
25
|
+
* would next try to sync against the new server. Harmless when nothing is open (the legacy
|
|
26
|
+
* per-operation cycle has already shut down). Never `api.shutdown`: it syncs before it
|
|
27
|
+
* closes, and syncing an in-memory copy that is stale against its own data dir (another
|
|
28
|
+
* process wrote that budget) fails "out-of-sync" and leaves the local copy permanently
|
|
29
|
+
* unopenable; every session open on that budget then fails until the copy is deleted. The
|
|
30
|
+
* v0.22.48 full run hit exactly that after the stdio #270 regression, which v0.22.47 survived
|
|
31
|
+
* because its session open only ran upstream's download, whose own close does not sync
|
|
32
|
+
* either. This removes only the session-open trigger: the adapter precondition still syncs
|
|
33
|
+
* the outgoing budget before re-selecting (so a pending change is not lost), and that sync
|
|
34
|
+
* carries the same stale-copy hazard when a second process writes the data dir (#575).
|
|
35
|
+
* Anything still pending syncs at that budget's next load. Not the pool's shutdown helpers: those drop pool entries, and the other sessions'
|
|
36
|
+
* entries are still valid.
|
|
37
|
+
* 3. log in, then record the login.
|
|
38
|
+
*
|
|
39
|
+
* Bounded by the op timeout (#270). Callers that retry on the auth rate limit wrap this whole
|
|
40
|
+
* call in `withAuthRetry`, so each attempt is bounded on its own.
|
|
41
|
+
*
|
|
42
|
+
* A timeout does not cancel (`withOpTimeout` races), so a timed-out close or login keeps running
|
|
43
|
+
* after the caller has thrown and released the lock. A late close would close the budget the next
|
|
44
|
+
* operation loaded, in any deployment, so it is always registered with the #393 abandoned-load
|
|
45
|
+
* registry: every `withApiLock` acquisition settles it, bounded and failing closed, before it runs
|
|
46
|
+
* anything. A late login is registered only when the registry names more than one login
|
|
47
|
+
* (`loginsSpanServers`): there it could move the client to another server under the next
|
|
48
|
+
* operation, while with one login it lands on the only server there is, and registering it would
|
|
49
|
+
* replace #270's per-request recovery with a fail-closed wait on every operation. The close
|
|
50
|
+
* has no such exemption: it waits on upstream's spreadsheet and services (not on running handlers), so a close
|
|
51
|
+
* that stalls makes every session wait, bounded and failing closed, in a one-login deployment
|
|
52
|
+
* too (the accepted #393 trade-off; making the close and login abortable is #574). Their late
|
|
53
|
+
* success records nothing (the records were cleared in step 1 and `setApiLogin` is only reached by
|
|
54
|
+
* a caller still waiting), so the next operation logs in again.
|
|
55
|
+
*/
|
|
56
|
+
export async function initApiLoggedIn(dataDir, login, label) {
|
|
57
|
+
setApiLogin(null);
|
|
58
|
+
setLoadedBudgetSyncId(null);
|
|
59
|
+
if (upstreamLib) {
|
|
60
|
+
const closing = upstreamLib.send('close-budget');
|
|
61
|
+
registerBudgetLoad(closing);
|
|
62
|
+
await withOpTimeout(() => closing, `${label} (close outgoing budget)`);
|
|
63
|
+
}
|
|
64
|
+
const loggingIn = api.init({
|
|
65
|
+
dataDir,
|
|
66
|
+
serverURL: login.serverUrl,
|
|
67
|
+
password: login.password,
|
|
68
|
+
});
|
|
69
|
+
if (loginsSpanServers())
|
|
70
|
+
registerBudgetLoad(loggingIn);
|
|
71
|
+
const lib = await withOpTimeout(() => loggingIn, label);
|
|
72
|
+
if (lib && typeof lib.send === 'function')
|
|
73
|
+
upstreamLib = lib;
|
|
74
|
+
setApiLogin(login);
|
|
75
|
+
}
|
package/dist/src/lib/apiState.js
CHANGED
|
@@ -25,8 +25,35 @@ export function setApiInitialized(value) {
|
|
|
25
25
|
_apiInitialized = value;
|
|
26
26
|
// A singleton that is not live holds no budget. Clearing here means a shutdown can never
|
|
27
27
|
// leave a stale claim behind for the next caller to trust.
|
|
28
|
-
if (!value)
|
|
28
|
+
if (!value) {
|
|
29
29
|
_loadedBudgetSyncId = null;
|
|
30
|
+
_apiLogin = null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
let _apiLogin = null;
|
|
34
|
+
export function setApiLogin(login) {
|
|
35
|
+
_apiLogin = login ? { serverUrl: login.serverUrl, password: login.password } : null;
|
|
36
|
+
}
|
|
37
|
+
export function isApiLoggedInTo(login) {
|
|
38
|
+
return !!_apiLogin && _apiLogin.serverUrl === login.serverUrl && _apiLogin.password === login.password;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* #571: whether the budget registry names more than one login (server URL and password).
|
|
42
|
+
*
|
|
43
|
+
* Decides whether a timed-out `api.init` is registered as an abandoned load. With ONE login a
|
|
44
|
+
* late-landing init points the client at the only server there is, which is harmless, and
|
|
45
|
+
* registering it would turn one stalled login into a fail-closed wait on every later operation
|
|
46
|
+
* until upstream gave up (the per-request recovery #270 guarantees, checked by
|
|
47
|
+
* tests/unit/adapter_op_timeout.test.js). With several, a late landing can move the client to
|
|
48
|
+
* another server under the next operation, so it must be settled first. Set once, by the adapter,
|
|
49
|
+
* from the parsed registry.
|
|
50
|
+
*/
|
|
51
|
+
let _loginsSpanServers = false;
|
|
52
|
+
export function setLoginsSpanServers(value) {
|
|
53
|
+
_loginsSpanServers = value;
|
|
54
|
+
}
|
|
55
|
+
export function loginsSpanServers() {
|
|
56
|
+
return _loginsSpanServers;
|
|
30
57
|
}
|
|
31
58
|
/**
|
|
32
59
|
* #390: the syncId of the budget the SINGLETON currently holds, as distinct from the budget
|
|
@@ -9,7 +9,7 @@ import { getLocalIp } from '../utils.js';
|
|
|
9
9
|
import actualToolsManager from '../actualToolsManager.js';
|
|
10
10
|
import { getConnectionState, connectToActualForSession, shutdownActualForSession, shutdownActual, canAcceptNewSession, isConnectionPoolEnabled } from '../actualConnection.js';
|
|
11
11
|
import { connectionPool } from '../lib/ActualConnectionPool.js';
|
|
12
|
-
import { clearSessionBudgetState, setSessionLivenessCheck } from '../lib/actual-adapter.js';
|
|
12
|
+
import { clearSessionBudgetState, setSessionLivenessCheck, initialBudgetForSession } from '../lib/actual-adapter.js';
|
|
13
13
|
import { errorLogFields } from '../lib/errors.js';
|
|
14
14
|
import observability from '../observability.js';
|
|
15
15
|
import config from '../config.js';
|
|
@@ -538,7 +538,15 @@ advertisedUrl) {
|
|
|
538
538
|
sessionInitPromises.set(sid, initPromise);
|
|
539
539
|
// Initialize connection pool for this session
|
|
540
540
|
try {
|
|
541
|
-
|
|
541
|
+
// #571: open on the session's own budget (a restored #189 preference may live on
|
|
542
|
+
// another server), not on the default one.
|
|
543
|
+
const budget = initialBudgetForSession(sid);
|
|
544
|
+
await connectToActualForSession(sid, {
|
|
545
|
+
serverUrl: budget.serverUrl,
|
|
546
|
+
password: budget.password || '',
|
|
547
|
+
syncId: budget.syncId,
|
|
548
|
+
encryptionPassword: budget.encryptionPassword,
|
|
549
|
+
});
|
|
542
550
|
// Only register the transport if the pool connection succeeded.
|
|
543
551
|
// The pool stamped lastActivity when it created the entry, so it
|
|
544
552
|
// is already the source of truth for this session's idle clock.
|
|
@@ -3,7 +3,7 @@ import adapter from '../lib/actual-adapter.js';
|
|
|
3
3
|
const InputSchema = z.object({});
|
|
4
4
|
const tool = {
|
|
5
5
|
name: 'actual_budgets_get_all',
|
|
6
|
-
description: 'Get
|
|
6
|
+
description: 'Get the budget files on the server that this session may use. Useful for multi-budget management and discovering available budgets.',
|
|
7
7
|
inputSchema: InputSchema,
|
|
8
8
|
call: async (_args, _meta) => {
|
|
9
9
|
const result = await adapter.getBudgets();
|
|
@@ -7,13 +7,12 @@ import adapter from '../lib/actual-adapter.js';
|
|
|
7
7
|
* TWO SIDE EFFECTS EVERY CALLER MUST KNOW, both verified against `importActual` in
|
|
8
8
|
* the installed `@actual-app/api` and against a live server in the Docker E2E run:
|
|
9
9
|
*
|
|
10
|
-
* 1. IT CAN OVERWRITE AN EXISTING BUDGET. The budget
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* "a new budget" when the archive's id is not already present.
|
|
10
|
+
* 1. IT CAN OVERWRITE AN EXISTING BUDGET'S LOCAL COPY. The local budget directory is
|
|
11
|
+
* keyed by the id the ARCHIVE carries, so re-importing an export of budget X
|
|
12
|
+
* replaces X's local data. On the SERVER it never overwrites anything (#570):
|
|
13
|
+
* `importActual` clears the cloud file and group ids and uploads the result as a
|
|
14
|
+
* NEW budget file (best effort; a failed upload leaves it local only).
|
|
15
|
+
* A session with a restricted budget ACL is refused this tool outright (#570).
|
|
17
16
|
* 2. IT CHANGES THE ACTIVE BUDGET. The imported budget is loaded, so the session
|
|
18
17
|
* switches to it. If the archive's id is not in the `BUDGET_N_*` registry, that
|
|
19
18
|
* budget is also outside whatever set the ACL resolved for this request, and
|
|
@@ -66,7 +65,7 @@ export default createTool({
|
|
|
66
65
|
name: 'actual_budgets_import',
|
|
67
66
|
description: 'Restore or import a budget from an Actual Budget ".zip" export or a YNAB4/YNAB5 export. ' +
|
|
68
67
|
'DESTRUCTIVE: the budget id comes from the archive, so re-importing an export REPLACES that ' +
|
|
69
|
-
'budget\'s
|
|
68
|
+
'budget\'s local copy, and the result is uploaded to the server as a new budget file. Never call this to "have a look at" a file. Confirm ' +
|
|
70
69
|
'with the user before importing over a budget that holds real data. ' +
|
|
71
70
|
'It also LOADS the imported budget, so the active budget for this session changes; if the archive\'s ' +
|
|
72
71
|
'budget is not in the configured list, call actual_budgets_list_available and actual_budgets_switch ' +
|
|
@@ -3,7 +3,7 @@ import adapter from '../lib/actual-adapter.js';
|
|
|
3
3
|
const InputSchema = z.object({});
|
|
4
4
|
const tool = {
|
|
5
5
|
name: 'actual_budgets_list_available',
|
|
6
|
-
description: 'List
|
|
6
|
+
description: 'List the pre-configured budgets this session may switch to (a session limited to some budgets sees only those). ' +
|
|
7
7
|
'Each entry shows the budget name, sync ID, server URL, and whether it uses E2E encryption. ' +
|
|
8
8
|
'Budgets are configured via BUDGET_n_NAME / BUDGET_n_SYNC_ID / BUDGET_n_SERVER_URL env vars. ' +
|
|
9
9
|
'Pass the budget name to actual_budgets_switch to change the active budget.',
|
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.22.
|
|
4
|
+
"version": "0.22.49",
|
|
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/transactions_aggregate.test.js && node tests/unit/account_flow_summary.test.js && node tests/unit/recurring_expenses_summary.test.js && node tests/unit/generated_tools.smoke.test.js &&node tests/unit/schema_validation.test.js && node tests/unit/schema_json_openai_compat.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/e2e_spec_collection.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/new_tool_completeness.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/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.test.js && node tests/unit/write_effect_audit_staleness.test.js && node tests/unit/write_effect_audit_membership.test.js && node tests/unit/tool_annotations.test.js && node tests/unit/tool_id_schema_drift.test.js && node tests/unit/filter_id_resolution.test.js && node tests/unit/filter_id_tool_wiring.test.js && node tests/unit/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.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/adapter_set_budget_batch.test.js && node tests/unit/budget_updates_batch.test.js && node tests/unit/rules_create.test.js && node tests/unit/adapter_rule_reference_guard.test.js && node tests/unit/rules_create_batch.test.js && node tests/unit/budgets_hold_for_next_month.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/httpServer_init_failure_report.test.js && node tests/unit/stdio_init_failure_report.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/accounts_delete.test.js && node tests/unit/update_tools_not_found.test.js && node tests/unit/accounts_close.test.js && node tests/unit/accounts_reopen.test.js && node tests/unit/payees_delete.test.js && node tests/unit/payees_merge.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/session_ownership.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/oidc_resource_identifier.test.js && node tests/unit/httpServer_oidc_resource_metadata.test.js && node tests/unit/oidc_error_cause.test.js && node tests/unit/httpServer_oidc_error_body.test.js && node tests/unit/oidc_token_source.test.js && node tests/unit/config_oidc_assertion_refines.test.js && node tests/unit/httpServer_oidc_assertion_mode.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpErrorHandler.test.js && node tests/unit/httpServer_error_handler_wiring.test.js && node tests/unit/httpServer_trust_proxy.test.js && node tests/unit/httpServer_batch_cap.test.js && node tests/unit/dotenv_stdout_quiet.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/adapter_drain_listing_cache.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_selection_precondition.test.js && node tests/unit/budget_load_abandonment.test.js && node tests/unit/budget_loader_postcondition.test.js && node tests/unit/budget_loader_import_allowlist.test.js && node tests/unit/budget_import_tracking.test.js && node tests/unit/pool_shutdown_under_lock.test.js && node tests/unit/api_lock_affinity.test.js && node tests/unit/commit_message_closing_keyword.test.js && node tests/unit/write_queue_stranding.test.js && node tests/unit/write_queue_stranded_bound.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/transactions_update_splits.test.js && node tests/unit/transactions_update_split_convert.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/transactions_update_batch.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/stdio_budget_switch.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/tool_content_logging.test.js && node tests/unit/adapter_error_logging.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/playwright_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/batch_uncategorized_cleanup.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/server_version_probe_outside_op_timeout.test.js && node tests/unit/payee_rules_reference_filter.test.js && node tests/unit/oidc_metadata_discovery.test.js && node tests/unit/httpServer_oauth_metadata_route.test.js && node tests/unit/httpServer_mcp_info_route.test.js && node tests/unit/residue_sweep_balance_account.test.js && node tests/unit/tool_factory.test.js && node tests/unit/budget_registry_parse.test.js && node tests/unit/preferences_get.test.js && node tests/unit/budgets_export.test.js && node tests/unit/budgets_import.test.js && node tests/unit/budgets_import_syncid.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_identity_map.test.js && node tests/unit/budget_acl_userinfo.test.js && node tests/unit/acl_e2e_fixture_contract.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.test.js && node tests/unit/oidc_scope_list.test.js && node tests/unit/get_context.test.js && node tests/unit/contributor_docs.test.js && node tests/unit/toolsets.test.js && node tests/unit/toolsets_entrypoint.test.js && node tests/unit/tools_list_size_budget.test.js && node tests/unit/rules_update_schedules_schema.test.js && node tests/unit/schedules_update_amount_guard.test.js && node tests/unit/source_text_helper.test.js && node tests/unit/transport_onerror.test.js && node tests/unit/adapter_transaction_batch_abort.test.js && node tests/unit/transactions_merge_adapter.test.js && node tests/unit/schedules_dead_conditions.test.js && node tests/unit/session_ownership_express.test.js && node tests/unit/pool_orphan_sweep.test.js && node tests/unit/session_budget_state_cleanup.test.js && node tests/unit/http_session_delete.test.js && node tests/unit/budget_acl_parse.test.js && node tests/unit/server_instructions.test.js && node tests/unit/query_run_empty_hint.test.js",
|
|
33
|
+
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/transactions_aggregate.test.js && node tests/unit/account_flow_summary.test.js && node tests/unit/recurring_expenses_summary.test.js && node tests/unit/generated_tools.smoke.test.js &&node tests/unit/schema_validation.test.js && node tests/unit/schema_json_openai_compat.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/e2e_spec_collection.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/new_tool_completeness.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/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.test.js && node tests/unit/write_effect_audit_staleness.test.js && node tests/unit/write_effect_audit_membership.test.js && node tests/unit/tool_annotations.test.js && node tests/unit/tool_id_schema_drift.test.js && node tests/unit/filter_id_resolution.test.js && node tests/unit/filter_id_tool_wiring.test.js && node tests/unit/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.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/adapter_set_budget_batch.test.js && node tests/unit/budget_updates_batch.test.js && node tests/unit/rules_create.test.js && node tests/unit/adapter_rule_reference_guard.test.js && node tests/unit/rules_create_batch.test.js && node tests/unit/budgets_hold_for_next_month.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/httpServer_init_failure_report.test.js && node tests/unit/stdio_init_failure_report.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/accounts_delete.test.js && node tests/unit/update_tools_not_found.test.js && node tests/unit/accounts_close.test.js && node tests/unit/accounts_reopen.test.js && node tests/unit/payees_delete.test.js && node tests/unit/payees_merge.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/session_ownership.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/oidc_resource_identifier.test.js && node tests/unit/httpServer_oidc_resource_metadata.test.js && node tests/unit/oidc_error_cause.test.js && node tests/unit/httpServer_oidc_error_body.test.js && node tests/unit/oidc_token_source.test.js && node tests/unit/config_oidc_assertion_refines.test.js && node tests/unit/httpServer_oidc_assertion_mode.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpErrorHandler.test.js && node tests/unit/httpServer_error_handler_wiring.test.js && node tests/unit/httpServer_trust_proxy.test.js && node tests/unit/httpServer_batch_cap.test.js && node tests/unit/dotenv_stdout_quiet.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/adapter_drain_listing_cache.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_acl_listing.test.js && node tests/unit/budget_selection_precondition.test.js && node tests/unit/multi_server_budget_selection.test.js && node tests/unit/multi_server_budget_selection_stdio.test.js && node tests/unit/multi_server_session_open_http.test.js && node tests/unit/budget_load_abandonment.test.js && node tests/unit/budget_loader_postcondition.test.js && node tests/unit/budget_loader_import_allowlist.test.js && node tests/unit/budget_import_tracking.test.js && node tests/unit/pool_shutdown_under_lock.test.js && node tests/unit/api_lock_affinity.test.js && node tests/unit/commit_message_closing_keyword.test.js && node tests/unit/write_queue_stranding.test.js && node tests/unit/write_queue_stranded_bound.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/transactions_update_splits.test.js && node tests/unit/transactions_update_split_convert.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/transactions_update_batch.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/stdio_budget_switch.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/tool_content_logging.test.js && node tests/unit/adapter_error_logging.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/playwright_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/batch_uncategorized_cleanup.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/server_version_probe_outside_op_timeout.test.js && node tests/unit/payee_rules_reference_filter.test.js && node tests/unit/oidc_metadata_discovery.test.js && node tests/unit/httpServer_oauth_metadata_route.test.js && node tests/unit/httpServer_mcp_info_route.test.js && node tests/unit/residue_sweep_balance_account.test.js && node tests/unit/tool_factory.test.js && node tests/unit/budget_registry_parse.test.js && node tests/unit/preferences_get.test.js && node tests/unit/budgets_export.test.js && node tests/unit/budgets_import.test.js && node tests/unit/budgets_import_syncid.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_identity_map.test.js && node tests/unit/budget_acl_userinfo.test.js && node tests/unit/acl_e2e_fixture_contract.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.test.js && node tests/unit/oidc_scope_list.test.js && node tests/unit/get_context.test.js && node tests/unit/contributor_docs.test.js && node tests/unit/toolsets.test.js && node tests/unit/toolsets_entrypoint.test.js && node tests/unit/tools_list_size_budget.test.js && node tests/unit/rules_update_schedules_schema.test.js && node tests/unit/schedules_update_amount_guard.test.js && node tests/unit/source_text_helper.test.js && node tests/unit/transport_onerror.test.js && node tests/unit/adapter_transaction_batch_abort.test.js && node tests/unit/transactions_merge_adapter.test.js && node tests/unit/schedules_dead_conditions.test.js && node tests/unit/session_ownership_express.test.js && node tests/unit/pool_orphan_sweep.test.js && node tests/unit/session_budget_state_cleanup.test.js && node tests/unit/http_session_delete.test.js && node tests/unit/budget_acl_parse.test.js && node tests/unit/server_instructions.test.js && node tests/unit/query_run_empty_hint.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",
|