actual-mcp-server 0.22.31 → 0.22.40
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 -3
- package/dist/src/actualConnection.js +4 -0
- package/dist/src/actualToolsManager.js +20 -6
- package/dist/src/auth/budget-acl-parse.js +82 -0
- package/dist/src/auth/budget-acl.js +71 -36
- package/dist/src/config.js +29 -1
- package/dist/src/lib/ActualConnectionPool.js +91 -6
- package/dist/src/lib/actual-adapter.js +113 -9
- package/dist/src/lib/errors.js +25 -0
- package/dist/src/lib/retry.js +4 -3
- package/dist/src/lib/toolFactory.js +7 -5
- package/dist/src/lib/transportErrorLogger.js +69 -2
- package/dist/src/server/httpServer.js +63 -19
- package/dist/src/tools/categories_create.js +3 -1
- package/dist/src/tools/query_run.js +11 -6
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -525,8 +525,8 @@ All configuration is via environment variables. Copy `.env.example` to `.env` to
|
|
|
525
525
|
| `OIDC_JWKS_URI` | _(none)_ | No | Direct JWKS URL that bypasses OpenID discovery (#462), for issuers with no discovery document (Cloudflare Access: `https://<team>.cloudflareaccess.com/cdn-cgi/access/certs`). Same https, credentials and origin-or-trusted-host rules as a discovered `jwks_uri`, plus no query or fragment. When set, `/.well-known/oauth-authorization-server` is not served and `AUTH_BUDGET_ACL_IDENTITY_SOURCE=userinfo` is refused |
|
|
526
526
|
| `OIDC_TOKEN_SOURCE` | `authorization` | No | Where the bearer JWT is read from (#462): `authorization` (default) or `cf-access-jwt-assertion` for an MCP server behind Cloudflare Access with Managed OAuth (the edge forwards the signed Access JWT in that header; `Authorization` is then never consulted, `OIDC_SCOPES` must be empty, `OIDC_SCOPES_SUPPORTED` is allowed, and the origin must be reachable only via Cloudflare). See the Cloudflare recipe in `docs/guides/AI_CLIENT_SETUP.md` |
|
|
527
527
|
| `OIDC_SCOPES` | _(none)_ | No | Comma-separated required (enforced) scopes, also advertised. Leave empty when the IdP's JWTs carry no `scope` claim (Cloudflare Access for SaaS, or Casdoor when the client requests no scope) |
|
|
528
|
-
| `OIDC_SCOPES_SUPPORTED` | _(none)_ | No | Comma-separated scopes advertised in `scopes_supported` but not enforced. Use when the IdP's JWTs carry no `scope` claim but the client needs a scope such as `offline_access` advertised (e.g. Gemini with Cloudflare Access for SaaS). Required scopes from `OIDC_SCOPES` are always advertised too |
|
|
529
|
-
| `AUTH_BUDGET_ACL` | _(none)_ | No | Per-user budget ACL; see [AI Client Setup](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-multi-user) |
|
|
528
|
+
| `OIDC_SCOPES_SUPPORTED` | _(none)_ | No | Comma-separated scopes advertised in `scopes_supported` but not enforced. Use when the IdP's JWTs carry no `scope` claim but the client needs a scope such as `offline_access` advertised (e.g. Gemini with Cloudflare Access for SaaS). Required scopes from `OIDC_SCOPES` are always advertised too. Advertising `offline_access` unlocks a refresh token this server cannot revoke: see [Limiting a leaked refresh token](docs/SECURITY_AND_PRIVACY.md#limiting-a-leaked-refresh-token-488) |
|
|
529
|
+
| `AUTH_BUDGET_ACL` | _(none)_ | No | Per-user budget ACL; see [AI Client Setup](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-multi-user). Validated at startup: a value that is not a JSON object of string arrays refuses to start (#551). Unset or `{}` means no restrictions |
|
|
530
530
|
| `AUTH_BUDGET_ACL_SOURCE` | `static` | No | `static` uses the `AUTH_BUDGET_ACL` map above. `actual` derives the ACL from the Actual server's own per-file access list, so revoking someone in Actual takes effect here without a config edit. Requires a multi-user (OpenID) Actual server that was password-bootstrapped first |
|
|
531
531
|
| `AUTH_BUDGET_ACL_CLAIM` | `auto` | No | Which token claim identifies the principal when the source is `actual`. `auto` mirrors Actual's own precedence (`preferred_username`, `login`, `email`, `id`, `sub`) and matches the result against Actual's `userName`. Set a single claim name to pin one instead |
|
|
532
532
|
| `AUTH_BUDGET_ACL_IDENTITY_MAP` | _(empty)_ | No | Explicit `<sub>=<Actual userName>` bindings, comma separated, used when the source is `actual`. Consulted before the claim precedence and authoritative: a bound `sub` whose target matches no budget file is denied rather than falling back to a claim. Use it when your access token does not carry the claim Actual stored, or after an IdP-side rename |
|
|
@@ -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.40 | **Tool Count:** 84 (verified LibreChat-compatible)
|
|
840
840
|
|
|
841
841
|
## Sponsor
|
|
842
842
|
|
|
@@ -16,6 +16,10 @@ let initializing = false;
|
|
|
16
16
|
let initializationError = null;
|
|
17
17
|
// Feature flag to enable connection pooling - can be disabled via environment variable
|
|
18
18
|
let useConnectionPool = process.env.USE_CONNECTION_POOL !== 'false';
|
|
19
|
+
/** #530: httpServer registers sessions with the pool only when the pool is in use. */
|
|
20
|
+
export function isConnectionPoolEnabled() {
|
|
21
|
+
return useConnectionPool;
|
|
22
|
+
}
|
|
19
23
|
export async function connectToActual() {
|
|
20
24
|
if (initialized)
|
|
21
25
|
return;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// src/actualToolsManager.ts
|
|
2
2
|
import logger from './logger.js';
|
|
3
3
|
import { formatZodError } from './lib/zod-error-format.js';
|
|
4
|
+
import { errorLogFields } from './lib/errors.js';
|
|
4
5
|
import config from './config.js';
|
|
5
6
|
import { createModuleLogger } from './lib/loggerFactory.js';
|
|
6
7
|
import { resolvePublishedToolNames, unavailableError } from './lib/toolsets.js';
|
|
@@ -211,26 +212,39 @@ class ActualToolsManager {
|
|
|
211
212
|
toolsetLog.warn('Refused a call to an unpublished tool', { tool: name, setting: unavailable.setting });
|
|
212
213
|
throw unavailable;
|
|
213
214
|
}
|
|
215
|
+
const startTime = Date.now();
|
|
214
216
|
try {
|
|
215
217
|
const result = await tool.call(args);
|
|
216
218
|
// Force-serialize/deserialize to ensure result is JSON-safe (no circular refs,
|
|
217
219
|
// Buffers, class instances, etc). Convert undefined -> null so callers always
|
|
218
220
|
// receive a valid JSON value.
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
+
const json = JSON.stringify(result === undefined ? null : result);
|
|
222
|
+
const safe = JSON.parse(json);
|
|
223
|
+
// Content-free by design (#556): tool arguments and results are the user's budget
|
|
224
|
+
// data and never reach the logs. This record is the one place content COULD be
|
|
225
|
+
// logged, so an opt-in for debugging (#557) belongs here and nowhere else.
|
|
226
|
+
// `durationMs`, not `ms`: the logger reserves a top-level `ms` key (src/logger.ts).
|
|
227
|
+
logger.info('[TOOL RESULT]', { tool: name, durationMs: Date.now() - startTime, outcome: 'ok', bytes: Buffer.byteLength(json, 'utf8') });
|
|
221
228
|
return safe;
|
|
222
229
|
}
|
|
223
230
|
catch (err) {
|
|
224
231
|
// Format Zod validation errors into one actionable, consistent message (#206).
|
|
225
232
|
// Duck-type on `.issues` so any structurally-compatible ZodError is handled.
|
|
233
|
+
// The failure record mirrors the success one (#556): tool, duration and outcome,
|
|
234
|
+
// never the arguments. It never carries the error MESSAGE either (#559): that text
|
|
235
|
+
// is for the client and can quote input or upstream content. errorLogFields gives
|
|
236
|
+
// the class, a refusal's kind, and the throw site's withLogMessage text if any.
|
|
237
|
+
const failure = { tool: name, durationMs: Date.now() - startTime, outcome: 'error' };
|
|
226
238
|
if (err && typeof err === 'object' && 'issues' in err) {
|
|
227
239
|
const formattedMsg = formatZodError(err, tool.inputSchema);
|
|
228
|
-
|
|
240
|
+
// The #206 text is for the client only: a schema's custom refine message can quote
|
|
241
|
+
// input (transactions_create echoes the split amounts), so the log gets the issue
|
|
242
|
+
// codes, which come from Zod and never from the arguments.
|
|
243
|
+
const issueCodes = [...new Set((err.issues ?? []).map((i) => String(i?.code)))];
|
|
244
|
+
logger.error(`[TOOL ERROR] ${name}`, { ...failure, errorClass: 'ZodError', issueCodes });
|
|
229
245
|
throw new Error(formattedMsg);
|
|
230
246
|
}
|
|
231
|
-
|
|
232
|
-
const msg = e && typeof e.message === 'string' ? e.message : String(err);
|
|
233
|
-
logger.error(`[TOOL ERROR] ${name}: ${msg}`);
|
|
247
|
+
logger.error(`[TOOL ERROR] ${name}`, { ...failure, ...errorLogFields(err) });
|
|
234
248
|
throw err;
|
|
235
249
|
}
|
|
236
250
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// src/auth/budget-acl-parse.ts
|
|
2
|
+
//
|
|
3
|
+
// #551: the pure parser for AUTH_BUDGET_ACL, shared by config.ts (startup
|
|
4
|
+
// validation) and budget-acl.ts (runtime classification).
|
|
5
|
+
//
|
|
6
|
+
// Before #551 a set-but-invalid value was parsed lazily in budget-acl.ts, logged
|
|
7
|
+
// once, and replaced by an EMPTY map, which getAllowedBudgets read as "no ACL
|
|
8
|
+
// configured": every authenticated principal was granted every budget. A typo in
|
|
9
|
+
// the one variable that restricts users to their budgets opened them all. Now a
|
|
10
|
+
// set value is validated at startup, and the runtime path denies on failure.
|
|
11
|
+
//
|
|
12
|
+
// WHY THIS MODULE HAS NO IMPORTS. config.ts validates the raw env value at
|
|
13
|
+
// startup by calling parseBudgetAcl. If the parser lived in budget-acl.ts, config
|
|
14
|
+
// would import a module that imports config (an ESM cycle leaving one of them
|
|
15
|
+
// half-initialised at evaluation time) and would also pull in the chain
|
|
16
|
+
// budget-acl.ts -> budget-acl-dynamic.ts -> lib/actual-adapter.ts -> @actual-app/api
|
|
17
|
+
// into every config load, stdio included. Keeping this file dependency-free (no
|
|
18
|
+
// logger, no config, no zod) is what makes validation at config-parse time
|
|
19
|
+
// possible at all. It throws and never logs.
|
|
20
|
+
//
|
|
21
|
+
// ERROR-TEXT CONTRACT. A thrown message may contain only the variable name, the
|
|
22
|
+
// problem class (not JSON, not an object, entry not an array of non-empty
|
|
23
|
+
// strings), the zero-based entry index in PARSED key order (JSON.parse puts
|
|
24
|
+
// integer-like keys first, so it is not always the order the operator wrote; the
|
|
25
|
+
// message says so), and the JSON type found.
|
|
26
|
+
// Never a key, a value, JSON.parse's own message (on Node 22 it echoes the input,
|
|
27
|
+
// which may hold email addresses), or an Error `cause` carrying any of those. The
|
|
28
|
+
// message is printed to stderr at startup and logged at runtime, so it must be
|
|
29
|
+
// safe to surface verbatim.
|
|
30
|
+
function jsonTypeOf(value) {
|
|
31
|
+
if (value === null)
|
|
32
|
+
return 'null';
|
|
33
|
+
if (Array.isArray(value))
|
|
34
|
+
return 'array';
|
|
35
|
+
return typeof value;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Parse the raw `AUTH_BUDGET_ACL` value.
|
|
39
|
+
*
|
|
40
|
+
* Accepts a JSON object whose every value is an array of non-empty strings
|
|
41
|
+
* (`["*"]` is the wildcard). `{}` is valid and means no restrictions; an empty
|
|
42
|
+
* array for a principal is valid and denies that principal. Throws a TypeError
|
|
43
|
+
* under the error-text contract above on anything else, including whitespace-only
|
|
44
|
+
* input, `null`, an array, a scalar, a non-array entry, and an array holding a
|
|
45
|
+
* non-string or an empty string. The caller decides what unset and '' mean.
|
|
46
|
+
*/
|
|
47
|
+
export function parseBudgetAcl(raw) {
|
|
48
|
+
let parsed;
|
|
49
|
+
try {
|
|
50
|
+
parsed = JSON.parse(raw);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
throw new TypeError('AUTH_BUDGET_ACL: not valid JSON');
|
|
54
|
+
}
|
|
55
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
56
|
+
throw new TypeError(`AUTH_BUDGET_ACL: must be a JSON object (got ${jsonTypeOf(parsed)})`);
|
|
57
|
+
}
|
|
58
|
+
// Null prototype on purpose (see BudgetAclMap). On such an object a plain
|
|
59
|
+
// assignment of the "__proto__" key stores an own property, so no setter can fire.
|
|
60
|
+
const map = Object.create(null);
|
|
61
|
+
// The index follows Object.entries order: integer-like keys (a numeric `sub`)
|
|
62
|
+
// come first and a duplicate key keeps only its last copy, so the message
|
|
63
|
+
// states the order rather than letting the operator count lines in the file.
|
|
64
|
+
const order = '; indexes are zero-based in parsed key order, integer-like keys first';
|
|
65
|
+
let index = 0;
|
|
66
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
67
|
+
if (!Array.isArray(value)) {
|
|
68
|
+
throw new TypeError(`AUTH_BUDGET_ACL: entry ${index} must be an array of non-empty strings (got ${jsonTypeOf(value)}${order})`);
|
|
69
|
+
}
|
|
70
|
+
for (const id of value) {
|
|
71
|
+
if (typeof id !== 'string') {
|
|
72
|
+
throw new TypeError(`AUTH_BUDGET_ACL: entry ${index} must be an array of non-empty strings (got an array with a ${jsonTypeOf(id)} element${order})`);
|
|
73
|
+
}
|
|
74
|
+
if (id.length === 0) {
|
|
75
|
+
throw new TypeError(`AUTH_BUDGET_ACL: entry ${index} must be an array of non-empty strings (got an array with an empty string${order})`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
map[key] = [...value];
|
|
79
|
+
index++;
|
|
80
|
+
}
|
|
81
|
+
return map;
|
|
82
|
+
}
|
|
@@ -9,13 +9,21 @@
|
|
|
9
9
|
// { "<principal>": ["<syncId1>", "<syncId2>"] }
|
|
10
10
|
//
|
|
11
11
|
// Principal keys:
|
|
12
|
-
// "alice@example.com"
|
|
13
|
-
// "some-sub-uuid"
|
|
14
|
-
// "group:admin"
|
|
12
|
+
// "alice@example.com" matched against the token email claim
|
|
13
|
+
// "some-sub-uuid" matched against the token sub claim
|
|
14
|
+
// "group:admin" matched against the token groups/roles array
|
|
15
15
|
//
|
|
16
16
|
// Value ["*"] grants access to all budgets (admin shorthand).
|
|
17
17
|
//
|
|
18
|
-
//
|
|
18
|
+
// Three states, classified once from the raw value and cached (#551):
|
|
19
|
+
// open (AUTH_BUDGET_ACL unset, '' or '{}'): every authenticated user is
|
|
20
|
+
// allowed ("*"), as documented.
|
|
21
|
+
// map (a valid non-empty object): the per-principal lookup below.
|
|
22
|
+
// deny (a value that does not parse): every principal gets [] and a 403.
|
|
23
|
+
// config.ts refuses startup on such a value, so this state is a
|
|
24
|
+
// defence in depth for any caller that reaches the parser another way.
|
|
25
|
+
// Before #551 a parse failure degraded to an EMPTY map, which read as
|
|
26
|
+
// "open": a typo in the ACL granted every budget to every user.
|
|
19
27
|
//
|
|
20
28
|
// Usage in httpServer.ts:
|
|
21
29
|
// app.use(httpPath, budgetAclMiddleware); // after bearerAuth()
|
|
@@ -24,31 +32,36 @@
|
|
|
24
32
|
// to filter results by permitted sync IDs (forward-looking multi-budget support).
|
|
25
33
|
import config from '../config.js';
|
|
26
34
|
import logger from '../logger.js';
|
|
35
|
+
import { parseBudgetAcl } from './budget-acl-parse.js';
|
|
27
36
|
import { resolveAllowedBudgetsFromActual, resolvePrincipalAsync } from './budget-acl-dynamic.js';
|
|
28
|
-
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (!config.AUTH_BUDGET_ACL) {
|
|
36
|
-
_aclMap = {};
|
|
37
|
-
return _aclMap;
|
|
38
|
-
}
|
|
37
|
+
let _aclState = null;
|
|
38
|
+
// Test seam (#551): when set, classify this raw value instead of config.AUTH_BUDGET_ACL.
|
|
39
|
+
let _rawOverride = null;
|
|
40
|
+
function classify(raw) {
|
|
41
|
+
if (raw === undefined || raw === '')
|
|
42
|
+
return { kind: 'open' };
|
|
43
|
+
let map;
|
|
39
44
|
try {
|
|
40
|
-
|
|
41
|
-
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
42
|
-
throw new TypeError('AUTH_BUDGET_ACL must be a JSON object');
|
|
43
|
-
}
|
|
44
|
-
_aclMap = parsed;
|
|
45
|
-
logger.info(`[ACL] Budget ACL loaded: ${Object.keys(_aclMap).length} principal(s)`);
|
|
45
|
+
map = parseBudgetAcl(raw);
|
|
46
46
|
}
|
|
47
47
|
catch (err) {
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
// Only the parser's message, which is safe by contract (it never names a key
|
|
49
|
+
// or a value); never the raw error object, whose cause or stack could.
|
|
50
|
+
const reason = err instanceof Error ? err.message : 'AUTH_BUDGET_ACL: unparseable';
|
|
51
|
+
logger.error(`[ACL] Invalid AUTH_BUDGET_ACL: access denied to every principal until it is fixed (${reason})`);
|
|
52
|
+
return { kind: 'deny' };
|
|
50
53
|
}
|
|
51
|
-
|
|
54
|
+
const count = Object.keys(map).length;
|
|
55
|
+
if (count === 0)
|
|
56
|
+
return { kind: 'open' };
|
|
57
|
+
logger.info(`[ACL] Budget ACL loaded: ${count} principal(s)`);
|
|
58
|
+
return { kind: 'map', map };
|
|
59
|
+
}
|
|
60
|
+
function getAclState() {
|
|
61
|
+
if (_aclState === null) {
|
|
62
|
+
_aclState = classify(_rawOverride !== null ? _rawOverride.raw : config.AUTH_BUDGET_ACL);
|
|
63
|
+
}
|
|
64
|
+
return _aclState;
|
|
52
65
|
}
|
|
53
66
|
// ---------------------------------------------------------------------------
|
|
54
67
|
// Public helpers
|
|
@@ -57,15 +70,23 @@ function getAclMap() {
|
|
|
57
70
|
* Returns the list of allowed budget sync-IDs for the currently authenticated
|
|
58
71
|
* request principal.
|
|
59
72
|
*
|
|
60
|
-
* - Returns `['*']`
|
|
73
|
+
* - Returns `['*']` in the open state (ACL unset, empty, or `{}`).
|
|
61
74
|
* - Returns `['*']` when any matched principal has wildcard access.
|
|
62
75
|
* - Returns `[]` when the user is authenticated but not in the ACL at all.
|
|
76
|
+
* - Returns `[]` for every principal in the deny state (the ACL value does not
|
|
77
|
+
* parse), never `['*']`.
|
|
78
|
+
*
|
|
79
|
+
* Lookups use Object.hasOwn so a principal named like an Object.prototype member
|
|
80
|
+
* (`constructor`, `toString`) is simply absent instead of matching the inherited
|
|
81
|
+
* function (which used to throw and surface as a 500).
|
|
63
82
|
*/
|
|
64
83
|
export function getAllowedBudgets(req) {
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
if (Object.keys(acl).length === 0)
|
|
84
|
+
const state = getAclState();
|
|
85
|
+
if (state.kind === 'open')
|
|
68
86
|
return ['*'];
|
|
87
|
+
if (state.kind === 'deny')
|
|
88
|
+
return [];
|
|
89
|
+
const acl = state.map;
|
|
69
90
|
const auth = req.auth;
|
|
70
91
|
if (!auth)
|
|
71
92
|
return [];
|
|
@@ -84,13 +105,15 @@ export function getAllowedBudgets(req) {
|
|
|
84
105
|
principals.push(`group:${g}`);
|
|
85
106
|
// Check for wildcard admin access first
|
|
86
107
|
for (const p of principals) {
|
|
87
|
-
if (acl[p]
|
|
108
|
+
if (Object.hasOwn(acl, p) && acl[p].includes('*'))
|
|
88
109
|
return ['*'];
|
|
89
110
|
}
|
|
90
111
|
// Collect all permitted sync-IDs
|
|
91
112
|
const permitted = new Set();
|
|
92
113
|
for (const p of principals) {
|
|
93
|
-
|
|
114
|
+
if (!Object.hasOwn(acl, p))
|
|
115
|
+
continue;
|
|
116
|
+
for (const id of acl[p])
|
|
94
117
|
permitted.add(id);
|
|
95
118
|
}
|
|
96
119
|
return [...permitted];
|
|
@@ -108,8 +131,8 @@ export function canAccessBudget(req, budgetSyncId) {
|
|
|
108
131
|
/**
|
|
109
132
|
* Express middleware that enforces the budget ACL when OIDC auth is active.
|
|
110
133
|
*
|
|
111
|
-
* - Falls through immediately when AUTH_PROVIDER !== 'oidc'
|
|
112
|
-
*
|
|
134
|
+
* - Falls through immediately when AUTH_PROVIDER !== 'oidc' (no-op, keeps
|
|
135
|
+
* backward compatibility). With OIDC and the ACL unset it attaches `['*']`.
|
|
113
136
|
* - Attaches `req.allowedBudgets` for downstream use (multi-budget tools).
|
|
114
137
|
* - Returns HTTP 403 if the authenticated user has no permitted budgets.
|
|
115
138
|
*
|
|
@@ -178,11 +201,23 @@ async function resolveDynamicAcl(req, res, next) {
|
|
|
178
201
|
res.status(403).json({ error: 'Forbidden: budget access could not be determined' });
|
|
179
202
|
}
|
|
180
203
|
}
|
|
181
|
-
/** Reset cached state (test helper). */
|
|
204
|
+
/** Reset cached state and the raw override (test helper). */
|
|
182
205
|
export function _resetAclForTests() {
|
|
183
|
-
|
|
206
|
+
_aclState = null;
|
|
207
|
+
_rawOverride = null;
|
|
184
208
|
}
|
|
185
|
-
/**
|
|
209
|
+
/**
|
|
210
|
+
* Directly set the ACL map (test helper; bypasses config/env). `{}` is the open
|
|
211
|
+
* state, exactly as an unset variable is.
|
|
212
|
+
*/
|
|
186
213
|
export function _setAclForTests(aclMap) {
|
|
187
|
-
|
|
214
|
+
_aclState = Object.keys(aclMap).length === 0 ? { kind: 'open' } : { kind: 'map', map: aclMap };
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* #551 test seam: classify `raw` as if it were the AUTH_BUDGET_ACL value, through
|
|
218
|
+
* the same parser and logging as the real path. Cleared by _resetAclForTests.
|
|
219
|
+
*/
|
|
220
|
+
export function _setRawAclForTests(raw) {
|
|
221
|
+
_rawOverride = { raw };
|
|
222
|
+
_aclState = null;
|
|
188
223
|
}
|
package/dist/src/config.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { parseIdentityMap } from './auth/identity-map.js';
|
|
3
|
+
import { parseBudgetAcl } from './auth/budget-acl-parse.js';
|
|
3
4
|
// #466: the byte count body-parser will ACTUALLY enforce for MCP_HTTP_BODY_LIMIT, or
|
|
4
5
|
// null when the value must be refused. Mirrors `bytes.parse` (what body-parser uses)
|
|
5
6
|
// without importing that transitive package: floor(number * unit), units b and kb
|
|
@@ -192,7 +193,34 @@ export const configSchema = z.object({
|
|
|
192
193
|
// Keys: email, sub, or "group:<name>". Values: array of sync IDs or ["*"] for all.
|
|
193
194
|
// Example: {"alice@example.com":["budget-1"],"group:admin":["*"]}
|
|
194
195
|
// Leave unset to allow all authenticated users to access all budgets.
|
|
195
|
-
|
|
196
|
+
// #551: a SET value must parse, whatever AUTH_PROVIDER and AUTH_BUDGET_ACL_SOURCE
|
|
197
|
+
// are. Unset and '' mean "no restrictions" and are not validated. Before this
|
|
198
|
+
// refine an invalid value was parsed lazily at runtime and degraded to an empty
|
|
199
|
+
// map, which the ACL read as "open": a typo granted every budget to every user.
|
|
200
|
+
// The parser is a zero-import leaf so this file can call it (see its header).
|
|
201
|
+
AUTH_BUDGET_ACL: z
|
|
202
|
+
.string()
|
|
203
|
+
.optional()
|
|
204
|
+
.superRefine((v, ctx) => {
|
|
205
|
+
if (v === undefined || v === '')
|
|
206
|
+
return;
|
|
207
|
+
try {
|
|
208
|
+
parseBudgetAcl(v);
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
// The parser's message is safe by contract (variable name, problem class,
|
|
212
|
+
// entry index and JSON type only; never a key, a value or JSON.parse's
|
|
213
|
+
// echo of the input). Its own "AUTH_BUDGET_ACL: " prefix is dropped here
|
|
214
|
+
// because the sentence already names the variable.
|
|
215
|
+
const reason = (err instanceof Error ? err.message : String(err)).replace(/^AUTH_BUDGET_ACL: /, '');
|
|
216
|
+
ctx.addIssue({
|
|
217
|
+
code: 'custom',
|
|
218
|
+
message: `AUTH_BUDGET_ACL is malformed (${reason}). ` +
|
|
219
|
+
'Expected a JSON object mapping each principal to an array of budget sync ids, for example ' +
|
|
220
|
+
'{"<email or sub>":["<sync id>"],"group:<name>":["*"]}. See docs/CONFIGURATION.md.',
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}),
|
|
196
224
|
// #338: where the budget ACL comes from.
|
|
197
225
|
// 'static' (default) = AUTH_BUDGET_ACL above, exactly as before.
|
|
198
226
|
// 'actual' = derived from the Actual server's own per-file access
|
|
@@ -36,6 +36,22 @@ class ActualConnectionPool {
|
|
|
36
36
|
// a lazily-cleaned table would leak transport objects for sessions a client
|
|
37
37
|
// abandons without reconnecting.
|
|
38
38
|
evictionCallbacks = [];
|
|
39
|
+
// #530: every HTTP session the transport layer registered, with the time it was last
|
|
40
|
+
// served. An entry in `connections` is not the only thing that must expire: an
|
|
41
|
+
// infra-error drop (or `switchBudget`'s slow path) removes the pool entry WITHOUT
|
|
42
|
+
// evicting the transport, and before #530 a client that then went away left its
|
|
43
|
+
// transport, owner record and init promise behind forever, because nothing timed an
|
|
44
|
+
// entry-less session. The sweep reaps a registered id that has no entry and was not
|
|
45
|
+
// served for one idle window ("orphaned"). `touch()` stamps it, so a served request
|
|
46
|
+
// keeps the session alive whether or not the entry is back.
|
|
47
|
+
registered = new Map();
|
|
48
|
+
// #530 (review M1): requests currently being served per session. The orphan sweep skips a
|
|
49
|
+
// session with a request in flight: an entry-less session running a long operation (an
|
|
50
|
+
// import past the idle window, or a request queued behind another session's drain) has
|
|
51
|
+
// stamped its registration only at the START of the request, so without this the sweep
|
|
52
|
+
// would close its transport mid-operation. Entries have the #392 lock re-check and the
|
|
53
|
+
// #378 in-drain touch for the same hazard; registrations have neither.
|
|
54
|
+
inFlight = new Map();
|
|
39
55
|
constructor() {
|
|
40
56
|
// Read from environment variable or default to 15
|
|
41
57
|
// @actual-app/api is a singleton, so concurrent sessions cause conflicts
|
|
@@ -116,7 +132,24 @@ class ActualConnectionPool {
|
|
|
116
132
|
* sweep so the two can never disagree about where the boundary is (#167).
|
|
117
133
|
*/
|
|
118
134
|
isExpired(conn) {
|
|
119
|
-
|
|
135
|
+
// #530: a registered session's clock is the later of the entry's lastActivity and its
|
|
136
|
+
// last served request, so a recycled entry cannot make a session look idle.
|
|
137
|
+
return this.isPastIdle(Math.max(conn.lastActivity, this.registered.get(conn.sessionId) ?? 0));
|
|
138
|
+
}
|
|
139
|
+
/** The one boundary for entries and registrations alike (#167, #530). */
|
|
140
|
+
isPastIdle(lastActivity) {
|
|
141
|
+
return (Date.now() - lastActivity) > this.IDLE_TIMEOUT;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* #530: the transport layer registers each HTTP session when it registers the transport,
|
|
145
|
+
* so the pool can time it even while it has no entry. Idempotent; stamps "now".
|
|
146
|
+
*/
|
|
147
|
+
registerSession(sessionId) {
|
|
148
|
+
this.registered.set(sessionId, Date.now());
|
|
149
|
+
}
|
|
150
|
+
/** #530: drop a registration (idempotent). Every eviction path calls this itself. */
|
|
151
|
+
forgetSession(sessionId) {
|
|
152
|
+
this.registered.delete(sessionId);
|
|
120
153
|
}
|
|
121
154
|
/**
|
|
122
155
|
* Refresh a session's activity timestamp. Called by the transport layer on
|
|
@@ -124,28 +157,61 @@ class ActualConnectionPool {
|
|
|
124
157
|
* this, the pool only stamped lastActivity at init/switch, so an actively
|
|
125
158
|
* used session's pool clock never advanced; httpServer kept a parallel
|
|
126
159
|
* activity map to compensate, which is the drift this consolidation removes.
|
|
127
|
-
* No-op for unknown sessions (does NOT create an entry).
|
|
160
|
+
* No-op for unknown sessions (does NOT create an entry). #530: also stamps the session's
|
|
161
|
+
* registration when it has one, so a session whose entry was dropped stays alive while
|
|
162
|
+
* it is being served (the #378 in-drain touch feeds the same clock). Never registers.
|
|
128
163
|
*/
|
|
129
164
|
touch(sessionId) {
|
|
165
|
+
const now = Date.now();
|
|
130
166
|
const conn = this.connections.get(sessionId);
|
|
131
167
|
if (conn) {
|
|
132
|
-
conn.lastActivity =
|
|
168
|
+
conn.lastActivity = now;
|
|
169
|
+
}
|
|
170
|
+
if (this.registered.has(sessionId)) {
|
|
171
|
+
this.registered.set(sessionId, now);
|
|
133
172
|
}
|
|
134
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* #530: mark a request as in flight for the session (and touch it). While the count is
|
|
176
|
+
* above zero the orphan sweep leaves the session alone. httpServer calls this on POST only:
|
|
177
|
+
* an open GET event stream is idle by nature and must not pin an orphan.
|
|
178
|
+
*/
|
|
179
|
+
beginRequest(sessionId) {
|
|
180
|
+
this.touch(sessionId);
|
|
181
|
+
this.inFlight.set(sessionId, (this.inFlight.get(sessionId) ?? 0) + 1);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* #530: the request has ended (response finished or the client went away). Drops the
|
|
185
|
+
* in-flight count and touches, so the idle window starts from the END of a long request,
|
|
186
|
+
* not its start. Idempotent for an unknown or already-evicted session.
|
|
187
|
+
*/
|
|
188
|
+
endRequest(sessionId) {
|
|
189
|
+
const n = this.inFlight.get(sessionId);
|
|
190
|
+
if (n !== undefined) {
|
|
191
|
+
if (n <= 1)
|
|
192
|
+
this.inFlight.delete(sessionId);
|
|
193
|
+
else
|
|
194
|
+
this.inFlight.set(sessionId, n - 1);
|
|
195
|
+
}
|
|
196
|
+
this.touch(sessionId);
|
|
197
|
+
}
|
|
135
198
|
/**
|
|
136
199
|
* Register a listener invoked when the pool removes a session (idle sweep or
|
|
137
200
|
* explicit close). httpServer registers one that closes the transport and
|
|
138
201
|
* drops its table entries, keeping both tables consistent (#167). Listeners
|
|
139
202
|
* must not throw; any error is logged and swallowed so one bad listener
|
|
140
|
-
* cannot abort the removal of others.
|
|
203
|
+
* cannot abort the removal of others. #530: the listener also receives the reason
|
|
204
|
+
* (`evicted` for the #167 paths, `orphaned` for the entry-less sweep).
|
|
141
205
|
*/
|
|
142
206
|
onSessionEvicted(cb) {
|
|
143
207
|
this.evictionCallbacks.push(cb);
|
|
144
208
|
}
|
|
145
|
-
fireEviction(sessionId) {
|
|
209
|
+
fireEviction(sessionId, reason = 'evicted') {
|
|
210
|
+
this.registered.delete(sessionId); // #530: whatever the path, the session is gone
|
|
211
|
+
this.inFlight.delete(sessionId); // #530: a late endRequest finds nothing to decrement
|
|
146
212
|
for (const cb of this.evictionCallbacks) {
|
|
147
213
|
try {
|
|
148
|
-
cb(sessionId);
|
|
214
|
+
cb(sessionId, reason);
|
|
149
215
|
}
|
|
150
216
|
catch (err) {
|
|
151
217
|
logger.error(`[ConnectionPool] Eviction listener threw for session ${sessionId} (ignoring):`, err);
|
|
@@ -632,6 +698,23 @@ class ActualConnectionPool {
|
|
|
632
698
|
await this.shutdownConnection(sessionId, { evict: true, onlyIfExpired: true });
|
|
633
699
|
}
|
|
634
700
|
}
|
|
701
|
+
// #530: registered sessions with no entry. Walked AFTER the entry evictions (each of
|
|
702
|
+
// which already dropped its registration) and synchronously: there is no api call to
|
|
703
|
+
// make, only the transport layer's tables to clear, so the lock is not involved and a
|
|
704
|
+
// request served in the meantime has already stamped the registration. A session with
|
|
705
|
+
// a request still in flight is never an orphan, however long that request runs.
|
|
706
|
+
const orphans = [];
|
|
707
|
+
for (const [sessionId, lastServed] of this.registered.entries()) {
|
|
708
|
+
if (!this.connections.has(sessionId) && !this.inFlight.has(sessionId) && this.isPastIdle(lastServed)) {
|
|
709
|
+
orphans.push(sessionId);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
if (orphans.length > 0) {
|
|
713
|
+
logger.info(`[ConnectionPool] Reaping ${orphans.length} orphaned sessions (no pool entry, idle for one window)`);
|
|
714
|
+
for (const sessionId of orphans) {
|
|
715
|
+
this.fireEviction(sessionId, 'orphaned');
|
|
716
|
+
}
|
|
717
|
+
}
|
|
635
718
|
}
|
|
636
719
|
/**
|
|
637
720
|
* Shutdown all connections and stop cleanup timer
|
|
@@ -680,6 +763,8 @@ class ActualConnectionPool {
|
|
|
680
763
|
// otherwise fire once per session at the exact moment the code chose not to take it.
|
|
681
764
|
await teardown(true).catch(() => { });
|
|
682
765
|
});
|
|
766
|
+
this.registered.clear(); // #530: the transport layer is going down with the pool
|
|
767
|
+
this.inFlight.clear();
|
|
683
768
|
logger.info('[ConnectionPool] All connections shut down');
|
|
684
769
|
}
|
|
685
770
|
/**
|
|
@@ -13,7 +13,7 @@ import observability from '../observability.js';
|
|
|
13
13
|
import { retry, isRetryableError, isRateLimitError } from './retry.js';
|
|
14
14
|
import { withOpTimeout } from './opTimeout.js';
|
|
15
15
|
import { markInitFailure } from './init-failure.js';
|
|
16
|
-
import { NotFoundRefusal, OutOfRangeRefusal, constraintErrorMsg } from './errors.js';
|
|
16
|
+
import { NotFoundRefusal, OutOfRangeRefusal, constraintErrorMsg, errorLogFields } from './errors.js';
|
|
17
17
|
import { amountOpMismatch } from './schemas/schedule-amount.js';
|
|
18
18
|
import { findMatchingRule } from './rule-matching.js';
|
|
19
19
|
import logger from '../logger.js';
|
|
@@ -1198,7 +1198,9 @@ async function processWriteQueue() {
|
|
|
1198
1198
|
resolve(result);
|
|
1199
1199
|
}
|
|
1200
1200
|
catch (error) {
|
|
1201
|
-
|
|
1201
|
+
// Class and log-safe text only (#559): a refusal names the id the caller sent
|
|
1202
|
+
// (which can be a name, #388), and upstream text can quote the written values.
|
|
1203
|
+
logger.error('[WRITE QUEUE] Operation failed', errorLogFields(error));
|
|
1202
1204
|
// #419: remember an infrastructure-level failure so the batch-end
|
|
1203
1205
|
// shutdown self-heals (see drainForceFullShutdown above).
|
|
1204
1206
|
if (_shouldDropPoolOnError(error))
|
|
@@ -1656,7 +1658,8 @@ export async function createCategory(category) {
|
|
|
1656
1658
|
return normalizeToId(raw);
|
|
1657
1659
|
}
|
|
1658
1660
|
catch (error) {
|
|
1659
|
-
|
|
1661
|
+
// Upstream's message quotes the category name (#559), so the log gets the class only.
|
|
1662
|
+
logger.error('[CREATE CATEGORY] Error creating category', errorLogFields(error));
|
|
1660
1663
|
// Re-throw the error with proper context
|
|
1661
1664
|
if (error instanceof Error) {
|
|
1662
1665
|
throw error;
|
|
@@ -2672,7 +2675,8 @@ export async function updateRule(id, fields) {
|
|
|
2672
2675
|
// omitted conditions or actions takes the stored value, which contributes no new refs.
|
|
2673
2676
|
const addedRefs = newRuleReferences(rule, existingRule);
|
|
2674
2677
|
assertRuleReferencesExist(addedRefs, await loadRuleReferenceListings(addedRefs), RULE_REFUSAL_CHANGED);
|
|
2675
|
-
|
|
2678
|
+
// Rule id only: conditions and actions carry payee ids, notes and amounts (#556).
|
|
2679
|
+
logger.debug(`[UPDATE RULE] Updating rule ${id}`);
|
|
2676
2680
|
await withConcurrency(() => retry(() => rawUpdateRule(rule), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
|
|
2677
2681
|
logger.debug(`[UPDATE RULE] Update completed for rule ${id}`);
|
|
2678
2682
|
}, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
|
|
@@ -2794,19 +2798,116 @@ export async function getSchedules() {
|
|
|
2794
2798
|
return Array.isArray(raw) ? raw : [];
|
|
2795
2799
|
});
|
|
2796
2800
|
}
|
|
2801
|
+
/**
|
|
2802
|
+
* #547: upstream builds a schedule's rule with one `is` condition per field and STRINGIFIES the
|
|
2803
|
+
* value on create (`scheduleModel.fromExternal` in loot-core/src/server/api-models.ts), so an
|
|
2804
|
+
* OMITTED payee or account becomes the literal string "undefined". On update
|
|
2805
|
+
* (`api/schedule-update`) an EXISTING condition takes the raw value and only a MISSING one is
|
|
2806
|
+
* pushed as String(value); the server read back the literal "null" after a clear all the same,
|
|
2807
|
+
* and the layer that stringifies it was not identified, so the strip treats raw null, "null"
|
|
2808
|
+
* and "undefined" alike. None of them can ever equal an id, so the rule never links a
|
|
2809
|
+
* transaction: the schedule shows in the UI and in actual_schedules_get but is inert. Reproduced through the server on 2026-10-13 with
|
|
2810
|
+
* @actual-app/api 26.10.0 on every shape (no payee, no account, neither, payee cleared, account
|
|
2811
|
+
* cleared); the control with both fields linked its import.
|
|
2812
|
+
*
|
|
2813
|
+
* The repair drops the dead condition from the schedule's rule inside the SAME drain as the
|
|
2814
|
+
* schedule write, so the rule is right before the drain's api.sync() and no later reader sees
|
|
2815
|
+
* the inert shape. Only a condition whose value is nullish or one of those two literals is
|
|
2816
|
+
* dropped: if upstream stops emitting it there is nothing to strip and no rule write happens.
|
|
2817
|
+
* A schedule without a payee or an account is a legitimate shape (the UI builds it and the tool
|
|
2818
|
+
* description promises it), so it is repaired rather than refused.
|
|
2819
|
+
*
|
|
2820
|
+
* The rule is read back inside the drain, straight after a raw write (the #493 measurement:
|
|
2821
|
+
* upstream can resolve before the row lands), so the read is a bounded poll with the #489 shape.
|
|
2822
|
+
*
|
|
2823
|
+
* A RAW null value is treated as dead too. Upstream's rule engine would match it ("transactions
|
|
2824
|
+
* with no payee"), but the only way it reaches a schedule rule is through a field the caller sent
|
|
2825
|
+
* as null, and the tool contract says null CLEARS the link. Stripping it enforces that contract;
|
|
2826
|
+
* it never deletes a user-authored condition.
|
|
2827
|
+
*/
|
|
2828
|
+
const DEAD_SCHEDULE_CONDITION_VALUES = new Set(['undefined', 'null']);
|
|
2829
|
+
function isDeadScheduleCondition(cond, fields) {
|
|
2830
|
+
if (!fields.includes(cond?.field))
|
|
2831
|
+
return false;
|
|
2832
|
+
const v = cond.value;
|
|
2833
|
+
return v == null || (typeof v === 'string' && DEAD_SCHEDULE_CONDITION_VALUES.has(v));
|
|
2834
|
+
}
|
|
2835
|
+
/** Which of the two linkable fields the caller left unset (create) or set to null (update). */
|
|
2836
|
+
function unsetScheduleLinkFields(input, mode) {
|
|
2837
|
+
const unset = (field) => (mode === 'create' ? input[field] == null : input[field] === null);
|
|
2838
|
+
return ['payee', 'account'].filter(unset);
|
|
2839
|
+
}
|
|
2840
|
+
async function stripDeadScheduleConditions(scheduleId, fields, opStartedAt, mode) {
|
|
2841
|
+
if (fields.length === 0)
|
|
2842
|
+
return;
|
|
2843
|
+
const repair = fields.map((f) => `actual_schedules_update { ${f}: null }`).join(' or ');
|
|
2844
|
+
// Every message below avoids the TRANSIENT_ERROR_PATTERNS substrings ("timed out" included):
|
|
2845
|
+
// _shouldDropPoolOnError would otherwise drop the session's connection. Upstream causes stay
|
|
2846
|
+
// in `cause`, never in the text, for the same reason.
|
|
2847
|
+
const tail = mode === 'create'
|
|
2848
|
+
? `The schedule EXISTS, so do not create it again: repair it with ${repair}, or delete it with actual_schedules_delete.`
|
|
2849
|
+
: `The schedule was updated; retrying ${repair} is safe.`;
|
|
2850
|
+
const failed = (what, err) => new Error(`Schedule "${scheduleId}" was saved, but ${what}, so the ${fields.join(' and ')} condition may not be cleared and the schedule may not link any transaction. ${tail}`, { cause: err });
|
|
2851
|
+
// #494: same cap as the split poll, measured from the start of the operation.
|
|
2852
|
+
const T = config.ACTUAL_OP_TIMEOUT_MS;
|
|
2853
|
+
const deadline = T > 0
|
|
2854
|
+
? Math.min(Date.now() + SPLIT_VERIFY_TIMEOUT_MS, opStartedAt + Math.floor(T / 2))
|
|
2855
|
+
: Date.now() + SPLIT_VERIFY_TIMEOUT_MS;
|
|
2856
|
+
const boundMs = T > 0 ? Math.min(SPLIT_VERIFY_TIMEOUT_MS, Math.floor(T / 2)) : SPLIT_VERIFY_TIMEOUT_MS;
|
|
2857
|
+
let rule;
|
|
2858
|
+
for (;;) {
|
|
2859
|
+
let rules;
|
|
2860
|
+
try {
|
|
2861
|
+
rules = await withConcurrency(() => retry(() => rawGetRules(), { retries: 2, backoffMs: 200 }));
|
|
2862
|
+
}
|
|
2863
|
+
catch (err) {
|
|
2864
|
+
throw failed('its rule could not be read back', err);
|
|
2865
|
+
}
|
|
2866
|
+
rule = (Array.isArray(rules) ? rules : []).find((r) => (Array.isArray(r?.actions) ? r.actions : []).some((a) => a?.op === 'link-schedule' && a?.value === scheduleId));
|
|
2867
|
+
if (rule)
|
|
2868
|
+
break;
|
|
2869
|
+
if (Date.now() >= deadline) {
|
|
2870
|
+
// Thrown from the callback so the caller sees it (the drain's closing api.sync() still
|
|
2871
|
+
// saves the schedule).
|
|
2872
|
+
throw new Error(`Schedule "${scheduleId}" was saved, but its rule was not visible within ${boundMs}ms, so the ${fields.join(' and ')} ` +
|
|
2873
|
+
`condition could not be cleared and the schedule will not link any transaction. ${tail}`);
|
|
2874
|
+
}
|
|
2875
|
+
await new Promise((resolve) => setTimeout(resolve, SPLIT_VERIFY_INTERVAL_MS));
|
|
2876
|
+
}
|
|
2877
|
+
const conditions = Array.isArray(rule.conditions) ? rule.conditions : [];
|
|
2878
|
+
// Review round 1: the rule is in hand, so drop EVERY dead link condition, not only the fields
|
|
2879
|
+
// this call unset. A schedule created before this fix with a payee and no account carries a
|
|
2880
|
+
// dead account condition; the prescribed repair `{ payee: null }` must clear it too, or the
|
|
2881
|
+
// call reports success and the schedule still never links. `fields` only decides whether the
|
|
2882
|
+
// strip runs at all (a name-only update does not reach here) and words the error.
|
|
2883
|
+
const kept = conditions.filter((c) => !isDeadScheduleCondition(c, ['payee', 'account']));
|
|
2884
|
+
if (kept.length === conditions.length)
|
|
2885
|
+
return;
|
|
2886
|
+
try {
|
|
2887
|
+
await withConcurrency(() => retry(() => rawUpdateRule({ ...rule, conditions: kept }), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
|
|
2888
|
+
}
|
|
2889
|
+
catch (err) {
|
|
2890
|
+
throw failed('its rule could not be rewritten', err);
|
|
2891
|
+
}
|
|
2892
|
+
}
|
|
2797
2893
|
export async function createSchedule(schedule) {
|
|
2798
2894
|
observability.incrementToolCall('actual.schedules.create').catch(() => { });
|
|
2799
2895
|
return queueWriteOperation(async () => {
|
|
2896
|
+
const opStartedAt = Date.now();
|
|
2897
|
+
const input = (schedule ?? {});
|
|
2800
2898
|
// Note: rawCreateSchedule(schedule) passes the external schedule object directly.
|
|
2801
|
-
// Do NOT wrap in { schedule: ... }
|
|
2802
|
-
const raw = await withConcurrency(() => retry(() => rawCreateSchedule(
|
|
2899
|
+
// Do NOT wrap in { schedule: ... }: that would double-nest and break date parsing.
|
|
2900
|
+
const raw = await withConcurrency(() => retry(() => rawCreateSchedule(input), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
|
|
2803
2901
|
const id = normalizeToId(raw);
|
|
2902
|
+
// #547: an omitted payee or account is stored as a condition that can never match.
|
|
2903
|
+
await stripDeadScheduleConditions(id, unsetScheduleLinkFields(input, 'create'), opStartedAt, 'create');
|
|
2804
2904
|
return id;
|
|
2805
2905
|
}, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
|
|
2806
2906
|
}
|
|
2807
2907
|
export async function updateSchedule(id, fields, resetNextDate) {
|
|
2808
2908
|
observability.incrementToolCall('actual.schedules.update').catch(() => { });
|
|
2809
2909
|
return queueWriteOperation(async () => {
|
|
2910
|
+
const opStartedAt = Date.now();
|
|
2810
2911
|
// #527: upstream MERGES a partial update, so `amountOp` alone keeps the stored amount and
|
|
2811
2912
|
// `amount` alone keeps the stored op. Whether the pair fits ('isbetween' takes a
|
|
2812
2913
|
// { num1, num2 } range, every other op a number) therefore depends on the stored row when
|
|
@@ -2829,6 +2930,8 @@ export async function updateSchedule(id, fields, resetNextDate) {
|
|
|
2829
2930
|
}
|
|
2830
2931
|
}
|
|
2831
2932
|
await withConcurrency(() => retry(() => rawUpdateSchedule(id, patch, resetNextDate), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
|
|
2933
|
+
// #547: a payee or account cleared with null is stored as the literal string "null".
|
|
2934
|
+
await stripDeadScheduleConditions(id, unsetScheduleLinkFields(patch, 'update'), opStartedAt, 'update');
|
|
2832
2935
|
}, { preservesListings: PRESERVES_ALL_ENTITY_LISTINGS });
|
|
2833
2936
|
}
|
|
2834
2937
|
/**
|
|
@@ -3343,7 +3446,7 @@ export async function runQuery(queryString) {
|
|
|
3343
3446
|
catch (err) {
|
|
3344
3447
|
// Catch errors from the query execution to prevent unhandled rejections
|
|
3345
3448
|
const msg = err?.message || String(err);
|
|
3346
|
-
logger.error(
|
|
3449
|
+
logger.error('[ADAPTER] Query execution error', errorLogFields(err)); // #559: msg can quote the query
|
|
3347
3450
|
if (msg.includes('does not exist in table') || msg.includes('Field') || msg.includes('does not exist')) {
|
|
3348
3451
|
throw new Error(`Invalid field in query: ${msg}`);
|
|
3349
3452
|
}
|
|
@@ -3451,7 +3554,7 @@ export async function runQuery(queryString) {
|
|
|
3451
3554
|
catch (err) {
|
|
3452
3555
|
// Catch errors from the query execution to prevent unhandled rejections
|
|
3453
3556
|
const msg = err?.message || String(err);
|
|
3454
|
-
logger.error(
|
|
3557
|
+
logger.error('[ADAPTER] Query execution error', errorLogFields(err)); // #559: msg can quote the query
|
|
3455
3558
|
if (msg.includes('does not exist in table') || msg.includes('Field') || msg.includes('does not exist')) {
|
|
3456
3559
|
throw new Error(`Invalid field in query: ${msg}`);
|
|
3457
3560
|
}
|
|
@@ -3486,7 +3589,8 @@ export async function runQuery(queryString) {
|
|
|
3486
3589
|
catch (error) {
|
|
3487
3590
|
// Top-level catch to ensure no unhandled rejections escape
|
|
3488
3591
|
const errorMsg = error?.message || String(error);
|
|
3489
|
-
|
|
3592
|
+
// The message can embed the whole query (#559); the client still receives it below.
|
|
3593
|
+
logger.error('[ADAPTER] Query execution failed', errorLogFields(error));
|
|
3490
3594
|
// If the error already contains formatted validation errors with suggestions, preserve them
|
|
3491
3595
|
if (errorMsg.includes('Invalid SQL query:') && (errorMsg.includes('Available fields:') || errorMsg.includes('Available tables:'))) {
|
|
3492
3596
|
throw error; // Re-throw the well-formatted validation error without wrapping
|
package/dist/src/lib/errors.js
CHANGED
|
@@ -148,3 +148,28 @@ export function constraintErrorMsg(entityType, id, listTool) {
|
|
|
148
148
|
return `Failed to delete ${entityType.toLowerCase()} "${id}". ` +
|
|
149
149
|
`It may be referenced by other records. Use ${listTool} to verify it exists.`;
|
|
150
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* Log-safe error text (#559). An error's MESSAGE is for the client and may quote what the
|
|
153
|
+
* caller sent: a category name, a query, a rule value, or upstream text we do not control.
|
|
154
|
+
* The server never logs it. `withLogMessage` attaches a separate, content-free sentence
|
|
155
|
+
* that the `[TOOL ERROR]` record may log instead; set it where the error is thrown, and
|
|
156
|
+
* keep anything the caller supplied out of it.
|
|
157
|
+
*/
|
|
158
|
+
const LOG_MESSAGE = Symbol.for('actual-mcp-server.logMessage');
|
|
159
|
+
export function withLogMessage(error, logMessage) {
|
|
160
|
+
Object.defineProperty(error, LOG_MESSAGE, { value: logMessage, enumerable: false });
|
|
161
|
+
return error;
|
|
162
|
+
}
|
|
163
|
+
export function errorLogFields(error) {
|
|
164
|
+
const errorClass = error instanceof Error ? (Object.getPrototypeOf(error)?.constructor?.name || 'Error') : error === null ? 'null' : typeof error;
|
|
165
|
+
const fields = { errorClass };
|
|
166
|
+
if (isPreflightRefusal(error))
|
|
167
|
+
fields.refusalKind = error.refusalKind;
|
|
168
|
+
const code = typeof error === 'object' && error !== null ? error.code : undefined;
|
|
169
|
+
if (typeof code === 'string' && /^E[A-Z0-9_]{1,40}$/.test(code))
|
|
170
|
+
fields.code = code;
|
|
171
|
+
const logMessage = typeof error === 'object' && error !== null ? error[LOG_MESSAGE] : undefined;
|
|
172
|
+
if (typeof logMessage === 'string')
|
|
173
|
+
fields.logMessage = logMessage;
|
|
174
|
+
return fields;
|
|
175
|
+
}
|
package/dist/src/lib/retry.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { DEFAULT_RETRY_ATTEMPTS, DEFAULT_RETRY_BACKOFF_MS, MAX_RETRY_DELAY_MS } from './constants.js';
|
|
2
2
|
import { ModuleLoggers } from './loggerFactory.js';
|
|
3
|
+
import { errorLogFields } from './errors.js';
|
|
3
4
|
const log = ModuleLoggers.RETRY;
|
|
4
5
|
/**
|
|
5
6
|
* Error-message fragments that mark a TRANSIENT / infrastructure-level failure:
|
|
@@ -92,16 +93,16 @@ export async function retry(fn, opts) {
|
|
|
92
93
|
// (#177). With no classifier, behaviour is unchanged (retry until the
|
|
93
94
|
// attempt budget is exhausted), preserving every existing call site.
|
|
94
95
|
if (isRetryable && !isRetryable(err)) {
|
|
95
|
-
log.debug('Not retrying non-transient error',
|
|
96
|
+
log.debug('Not retrying non-transient error', errorLogFields(err));
|
|
96
97
|
throw err;
|
|
97
98
|
}
|
|
98
99
|
attempt++;
|
|
99
100
|
if (attempt > retries) {
|
|
100
|
-
log.error(`All retry attempts exhausted after ${retries} tries`, err);
|
|
101
|
+
log.error(`All retry attempts exhausted after ${retries} tries`, undefined, errorLogFields(err));
|
|
101
102
|
throw err;
|
|
102
103
|
}
|
|
103
104
|
const delay = Math.min(backoffMs * Math.pow(2, attempt - 1), MAX_RETRY_DELAY_MS);
|
|
104
|
-
log.debug(`Retry attempt ${attempt}/${retries} after ${delay}ms`,
|
|
105
|
+
log.debug(`Retry attempt ${attempt}/${retries} after ${delay}ms`, errorLogFields(err));
|
|
105
106
|
await new Promise(r => setTimeout(r, delay));
|
|
106
107
|
}
|
|
107
108
|
}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { createModuleLogger } from './loggerFactory.js';
|
|
16
16
|
import observability from '../observability.js';
|
|
17
|
+
import { errorLogFields } from './errors.js';
|
|
17
18
|
const log = createModuleLogger('TOOLS');
|
|
18
19
|
/**
|
|
19
20
|
* Create a tool definition with automatic error handling and logging
|
|
@@ -54,23 +55,24 @@ export function createTool(config) {
|
|
|
54
55
|
call: async (args, meta) => {
|
|
55
56
|
const startTime = Date.now();
|
|
56
57
|
try {
|
|
58
|
+
// No args, input or result in these records (#556): they are budget data.
|
|
57
59
|
// Validate input against schema
|
|
58
|
-
log.debug(`Validating input for ${name}
|
|
60
|
+
log.debug(`Validating input for ${name}`);
|
|
59
61
|
const input = schema.parse(args || {});
|
|
60
62
|
// Execute handler
|
|
61
|
-
log.debug(`Executing ${name}
|
|
63
|
+
log.debug(`Executing ${name}`);
|
|
62
64
|
const result = await handler(input, meta);
|
|
63
65
|
// Log success
|
|
64
66
|
const duration = Date.now() - startTime;
|
|
65
|
-
log.debug(`${name} completed in ${duration}ms
|
|
67
|
+
log.debug(`${name} completed in ${duration}ms`);
|
|
66
68
|
// Track observability
|
|
67
69
|
await observability.incrementToolCall(name);
|
|
68
70
|
return { result };
|
|
69
71
|
}
|
|
70
72
|
catch (error) {
|
|
71
|
-
//
|
|
73
|
+
// Class and log-safe text only (#559): the message and stack can quote input.
|
|
72
74
|
const duration = Date.now() - startTime;
|
|
73
|
-
log.error(`${name} failed after ${duration}ms`,
|
|
75
|
+
log.error(`${name} failed after ${duration}ms`, undefined, { tool: name, durationMs: duration, outcome: 'error', ...errorLogFields(error) });
|
|
74
76
|
// Track observability (still increment even on failure)
|
|
75
77
|
await observability.incrementToolCall(name);
|
|
76
78
|
// Re-throw for MCP error handling
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* Volume is bounded by ONE per-process window limiter.
|
|
13
13
|
*/
|
|
14
14
|
import { createModuleLogger } from './loggerFactory.js';
|
|
15
|
+
import { requestContext } from './requestContext.js';
|
|
15
16
|
const MAX_DETAIL_CODE_POINTS = 200;
|
|
16
17
|
const CUT_CHARS = /[{["'`]/;
|
|
17
18
|
// Control (C0, C1), line/paragraph separators, bidi format characters, and (#535) the
|
|
@@ -47,12 +48,42 @@ export function sanitizeTransportErrorDetail(value) {
|
|
|
47
48
|
s = s ? `${s} ${OMITTED}` : OMITTED;
|
|
48
49
|
return s === '' ? '[no message]' : s;
|
|
49
50
|
}
|
|
50
|
-
|
|
51
|
+
function defaultSchedule(fn, delayMs) {
|
|
52
|
+
setTimeout(fn, delayMs).unref();
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Fixed-window limiter: first `max` calls per window pass, the rest are counted.
|
|
56
|
+
*
|
|
57
|
+
* The count is reported by whichever comes first: the next allowed call (it carries
|
|
58
|
+
* `suppressed` on its own record) or, when `onWindowEnd` is set, a flush scheduled on
|
|
59
|
+
* the FIRST suppressed call of the window for the window's end (#536: before that a
|
|
60
|
+
* burst that was the last error ever lost its count). Each path zeroes the count, so
|
|
61
|
+
* the other finds 0 and reports nothing: never twice. A late flush (busy event loop)
|
|
62
|
+
* reports whatever accumulated by then, and the next suppression after a flush
|
|
63
|
+
* schedules a fresh one, so while the process runs a count is delayed at worst. The
|
|
64
|
+
* default timer is unref'd by design (it must not hold a stdio process open), so a
|
|
65
|
+
* shutdown inside the window drops the pending count.
|
|
66
|
+
*/
|
|
51
67
|
export function createWindowLimiter(opts) {
|
|
52
68
|
let windowStart = 0;
|
|
53
69
|
let count = 0;
|
|
54
70
|
let suppressed = 0;
|
|
55
71
|
let started = false;
|
|
72
|
+
let flushPending = false;
|
|
73
|
+
const schedule = opts.schedule ?? defaultSchedule;
|
|
74
|
+
const flush = () => {
|
|
75
|
+
flushPending = false;
|
|
76
|
+
if (suppressed <= 0)
|
|
77
|
+
return;
|
|
78
|
+
const reported = suppressed;
|
|
79
|
+
suppressed = 0;
|
|
80
|
+
try {
|
|
81
|
+
opts.onWindowEnd?.(reported);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// a throwing reporter must not escape the timer callback
|
|
85
|
+
}
|
|
86
|
+
};
|
|
56
87
|
return {
|
|
57
88
|
take() {
|
|
58
89
|
const t = opts.now();
|
|
@@ -68,12 +99,48 @@ export function createWindowLimiter(opts) {
|
|
|
68
99
|
return { allowed: true, suppressed: reported };
|
|
69
100
|
}
|
|
70
101
|
suppressed++;
|
|
102
|
+
if (opts.onWindowEnd && !flushPending) {
|
|
103
|
+
flushPending = true;
|
|
104
|
+
try {
|
|
105
|
+
// Clamp to [0, windowMs]: a backward clock step would otherwise grow the
|
|
106
|
+
// delay past the window (and past Node's 2^31 ms timer limit, which fires
|
|
107
|
+
// after 1 ms and turns the summary into one line per error).
|
|
108
|
+
schedule(flush, Math.min(opts.windowMs, Math.max(0, windowStart + opts.windowMs - t)));
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
flushPending = false; // a failed schedule must not escape take(); the next suppression retries
|
|
112
|
+
}
|
|
113
|
+
}
|
|
71
114
|
return { allowed: false, suppressed: 0 };
|
|
72
115
|
},
|
|
73
116
|
};
|
|
74
117
|
}
|
|
118
|
+
let summaryLog;
|
|
119
|
+
/**
|
|
120
|
+
* #536: the window-end summary. One constant message, one number, never stdout.
|
|
121
|
+
* Logged inside `requestContext.exit`: the flush timer inherits the AsyncLocalStorage
|
|
122
|
+
* store of the request that first hit the limit (ALS propagates through setTimeout),
|
|
123
|
+
* and `addCorrelation` in src/logger.ts would stamp that one session's ids on an
|
|
124
|
+
* aggregate that counts errors from every session. Never throws.
|
|
125
|
+
*/
|
|
126
|
+
export function reportSuppressedTransportErrors(suppressed, logger) {
|
|
127
|
+
try {
|
|
128
|
+
const log = logger ?? (summaryLog = summaryLog ?? createModuleLogger('TRANSPORT'));
|
|
129
|
+
requestContext.exit(() => {
|
|
130
|
+
log.warn('MCP transport errors suppressed', { suppressed });
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// never throw into a timer callback
|
|
135
|
+
}
|
|
136
|
+
}
|
|
75
137
|
// ONE limiter per process so many HTTP sessions cannot multiply the budget.
|
|
76
|
-
const moduleLimiter = createWindowLimiter({
|
|
138
|
+
const moduleLimiter = createWindowLimiter({
|
|
139
|
+
max: 50,
|
|
140
|
+
windowMs: 60_000,
|
|
141
|
+
now: Date.now,
|
|
142
|
+
onWindowEnd: (n) => reportSuppressedTransportErrors(n),
|
|
143
|
+
});
|
|
77
144
|
function safeErrorName(error) {
|
|
78
145
|
try {
|
|
79
146
|
const name = error?.name;
|
|
@@ -7,7 +7,7 @@ import logger, { resolveRequestId } from '../logger.js';
|
|
|
7
7
|
import { attachTransportErrorLogger } from '../lib/transportErrorLogger.js';
|
|
8
8
|
import { getLocalIp } from '../utils.js';
|
|
9
9
|
import actualToolsManager from '../actualToolsManager.js';
|
|
10
|
-
import { getConnectionState, connectToActualForSession, shutdownActualForSession, shutdownActual, canAcceptNewSession } from '../actualConnection.js';
|
|
10
|
+
import { getConnectionState, connectToActualForSession, shutdownActualForSession, shutdownActual, canAcceptNewSession, isConnectionPoolEnabled } from '../actualConnection.js';
|
|
11
11
|
import { connectionPool } from '../lib/ActualConnectionPool.js';
|
|
12
12
|
import observability from '../observability.js';
|
|
13
13
|
import config from '../config.js';
|
|
@@ -23,6 +23,7 @@ import { parseScopeList } from '../lib/oidc-scopes.js';
|
|
|
23
23
|
import { budgetAclMiddleware } from '../auth/budget-acl.js';
|
|
24
24
|
import { runAclPreflight } from '../auth/budget-acl-dynamic.js';
|
|
25
25
|
import * as https from 'node:https';
|
|
26
|
+
import { finished } from 'node:stream';
|
|
26
27
|
import * as fs from 'node:fs';
|
|
27
28
|
// AsyncLocalStorage for request context: moved to src/lib/requestContext.ts
|
|
28
29
|
// so adapter code can import it without a circular dependency on httpServer.
|
|
@@ -235,8 +236,15 @@ advertisedUrl) {
|
|
|
235
236
|
// loser would silently get the generic 404, restoring this bug at random.
|
|
236
237
|
const INIT_FAILURE_TTL_MS = 60_000;
|
|
237
238
|
const INIT_FAILURE_MAX = 1000;
|
|
239
|
+
// #550: the record carries the principal that initiated the session (the same value
|
|
240
|
+
// recordSessionOwner stores on success) and is served only to that principal. A failed
|
|
241
|
+
// init records no owner in session-owners.ts, and the POST cause branch runs before the
|
|
242
|
+
// ownership guard, so without this ANY authenticated principal presenting the id got
|
|
243
|
+
// the cause reply for 60 s, which contradicts the unknown-id equivalence #529 gives
|
|
244
|
+
// initialized sessions. Under a static bearer or no auth every caller is the same
|
|
245
|
+
// principal, so those deployments keep the #438 behaviour unchanged.
|
|
238
246
|
const sessionInitFailures = new Map();
|
|
239
|
-
const rememberInitFailure = (sid, err) => {
|
|
247
|
+
const rememberInitFailure = (sid, err, owner) => {
|
|
240
248
|
const now = Date.now();
|
|
241
249
|
for (const [k, v] of sessionInitFailures) {
|
|
242
250
|
if (now - v.at > INIT_FAILURE_TTL_MS)
|
|
@@ -250,15 +258,22 @@ advertisedUrl) {
|
|
|
250
258
|
break;
|
|
251
259
|
sessionInitFailures.delete(oldest);
|
|
252
260
|
}
|
|
253
|
-
sessionInitFailures.set(sid, { ...classifyInitFailure(err), at: now });
|
|
261
|
+
sessionInitFailures.set(sid, { ...classifyInitFailure(err), at: now, owner });
|
|
254
262
|
};
|
|
255
|
-
/**
|
|
256
|
-
|
|
263
|
+
/**
|
|
264
|
+
* Peek. Never deletes. Returns undefined once the TTL has elapsed, and (#550) for a
|
|
265
|
+
* principal other than the one that initiated the session: the caller then falls
|
|
266
|
+
* through to the reply an unknown id gets, and the record is neither consumed nor
|
|
267
|
+
* extended. Strict equality, like sessionOwnerMatches.
|
|
268
|
+
*/
|
|
269
|
+
const peekInitFailure = (sid, principal) => {
|
|
257
270
|
const rec = sessionInitFailures.get(sid);
|
|
258
271
|
if (!rec)
|
|
259
272
|
return undefined;
|
|
260
273
|
if (Date.now() - rec.at > INIT_FAILURE_TTL_MS)
|
|
261
274
|
return undefined;
|
|
275
|
+
if (rec.owner !== principal)
|
|
276
|
+
return undefined;
|
|
262
277
|
return { cause: rec.cause, sentence: rec.sentence };
|
|
263
278
|
};
|
|
264
279
|
// safe fallback if index didn't provide implementedTools
|
|
@@ -279,21 +294,27 @@ advertisedUrl) {
|
|
|
279
294
|
// the matching transport in the same window. This is what keeps the two
|
|
280
295
|
// tables from drifting: no "alive in httpServer, dead in the pool" state, and
|
|
281
296
|
// no transport object left behind for a session the client abandoned.
|
|
282
|
-
|
|
297
|
+
// #530: the pool also reaps a session it has no entry for (an infra-error drop
|
|
298
|
+
// recycles the entry without evicting the transport) once it goes one idle
|
|
299
|
+
// window without a served request, with reason `orphaned`; same teardown. The
|
|
300
|
+
// body is synchronous on purpose: the pool walks its registrations in one pass.
|
|
301
|
+
connectionPool.onSessionEvicted(teardownSession);
|
|
302
|
+
function teardownSession(sessionId, reason) {
|
|
283
303
|
const transport = transports.get(sessionId);
|
|
284
304
|
if (transport) {
|
|
285
305
|
try {
|
|
286
306
|
transport.close?.();
|
|
287
307
|
}
|
|
288
308
|
catch (err) {
|
|
289
|
-
logger.debug(`[SESSION] Error closing transport for
|
|
309
|
+
logger.debug(`[SESSION] Error closing transport for ${reason} session ${sessionId} (ignoring): ${err}`);
|
|
290
310
|
}
|
|
291
311
|
}
|
|
292
312
|
transports.delete(sessionId);
|
|
293
313
|
sessionInitPromises.delete(sessionId);
|
|
294
314
|
forgetSessionOwner(sessionId);
|
|
295
|
-
|
|
296
|
-
|
|
315
|
+
connectionPool.forgetSession(sessionId);
|
|
316
|
+
logger.info(`[SESSION] Transport torn down for ${reason} session: ${sessionId}`);
|
|
317
|
+
}
|
|
297
318
|
// Authentication middleware
|
|
298
319
|
const authenticateRequest = (req, res) => {
|
|
299
320
|
// OIDC mode: verify the request principal directly rather than inferring auth
|
|
@@ -382,7 +403,8 @@ advertisedUrl) {
|
|
|
382
403
|
throw new Error('Tool name must be a string');
|
|
383
404
|
}
|
|
384
405
|
const name = rawName;
|
|
385
|
-
|
|
406
|
+
// Tool name only: arguments are budget data and never reach the logs (#556).
|
|
407
|
+
logger.debug(`[TOOL CALL] ${name}`);
|
|
386
408
|
// Prefer ActualMCPConnection executor if provided
|
|
387
409
|
if (typeof mcp.executeTool === 'function') {
|
|
388
410
|
const result = await mcp.executeTool(name, args ?? {});
|
|
@@ -511,6 +533,10 @@ advertisedUrl) {
|
|
|
511
533
|
// is already the source of truth for this session's idle clock.
|
|
512
534
|
recordSessionOwner(sid, sessionOwner);
|
|
513
535
|
transports.set(sid, transport);
|
|
536
|
+
// #530: let the pool time this session even after its entry is recycled.
|
|
537
|
+
// Pooling off (USE_CONNECTION_POOL=false) has no sweep, so nothing to register.
|
|
538
|
+
if (isConnectionPoolEnabled())
|
|
539
|
+
connectionPool.registerSession(sid);
|
|
514
540
|
logger.info(`[SESSION] Actual connection initialized for session: ${sid}`);
|
|
515
541
|
resolveInit?.();
|
|
516
542
|
}
|
|
@@ -523,7 +549,7 @@ advertisedUrl) {
|
|
|
523
549
|
// cause is known here and discarded, and the client's next request gets
|
|
524
550
|
// a bare "Session not found" that says nothing about a schema mismatch,
|
|
525
551
|
// a bad password or an unreachable server.
|
|
526
|
-
rememberInitFailure(sid, err);
|
|
552
|
+
rememberInitFailure(sid, err, sessionOwner);
|
|
527
553
|
rejectInit?.(err);
|
|
528
554
|
}
|
|
529
555
|
finally {
|
|
@@ -595,7 +621,7 @@ advertisedUrl) {
|
|
|
595
621
|
// enum-only contract through the very hole left open there.
|
|
596
622
|
let knownFailure;
|
|
597
623
|
try {
|
|
598
|
-
knownFailure = peekInitFailure(sessionId);
|
|
624
|
+
knownFailure = peekInitFailure(sessionId, resolvePrincipal(req));
|
|
599
625
|
}
|
|
600
626
|
catch {
|
|
601
627
|
knownFailure = undefined;
|
|
@@ -676,8 +702,18 @@ advertisedUrl) {
|
|
|
676
702
|
});
|
|
677
703
|
return;
|
|
678
704
|
}
|
|
679
|
-
// Refresh the pool's idle clock for this session (single source of truth, #167)
|
|
680
|
-
|
|
705
|
+
// Refresh the pool's idle clock for this session (single source of truth, #167) and
|
|
706
|
+
// count the request in flight (#530): the orphan sweep must not reap a session whose
|
|
707
|
+
// own request is still running. The END marker is the response's completion, not a
|
|
708
|
+
// finally block: with enableJsonResponse `handleRequest` can resolve before the body
|
|
709
|
+
// is written. `stream.finished` rather than `res.once('close')`: a client that gave
|
|
710
|
+
// up during the init wait above has ALREADY closed the response, its `close` event
|
|
711
|
+
// fired before this line ran, and a listener attached now would never run, leaving
|
|
712
|
+
// the count at one for good (review round 2). `finished` calls back for an
|
|
713
|
+
// already-closed response too, and once, whether the response completed, errored
|
|
714
|
+
// or the client went away.
|
|
715
|
+
connectionPool.beginRequest(sessionId);
|
|
716
|
+
finished(res, () => connectionPool.endRequest(sessionId));
|
|
681
717
|
// Run in AsyncLocalStorage context so tools and the adapter can access
|
|
682
718
|
// sessionId (pool branch, #134) and allowedBudgets (ACL enforcement, #156).
|
|
683
719
|
const allowedBudgets = req.allowedBudgets;
|
|
@@ -734,15 +770,17 @@ advertisedUrl) {
|
|
|
734
770
|
res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'No session id' }, id: null });
|
|
735
771
|
return;
|
|
736
772
|
}
|
|
737
|
-
// Same ownership rule as the POST route
|
|
738
|
-
//
|
|
739
|
-
//
|
|
773
|
+
// Same ownership rule as the POST route. A refusal reads like an unknown
|
|
774
|
+
// session. The `transports.has` guard is load bearing: a session whose init
|
|
775
|
+
// FAILED never had an owner recorded, and under OIDC or a static bearer every
|
|
776
|
+
// caller has a principal, so without it that session would be refused here
|
|
777
|
+
// instead of reaching the #438 cause report below. That report is owner-bound
|
|
778
|
+
// on its own since #550 (the peek takes the principal).
|
|
740
779
|
if (transports.has(sessionId) && !sessionOwnerMatches(sessionId, resolvePrincipal(req))) {
|
|
741
780
|
logger.warn(`[SESSION] Refused a GET for session ${sessionId} from a principal that does not own it`);
|
|
742
781
|
res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Transport not ready' }, id: null });
|
|
743
782
|
return;
|
|
744
783
|
}
|
|
745
|
-
connectionPool.touch(sessionId); // Refresh the pool's idle clock (#167)
|
|
746
784
|
const transport = transports.get(sessionId);
|
|
747
785
|
if (!transport) {
|
|
748
786
|
// #438: same cause, this route's own status and code. The classifier owns no
|
|
@@ -763,7 +801,7 @@ advertisedUrl) {
|
|
|
763
801
|
// NODE_ENV is not production.
|
|
764
802
|
let knownFailure;
|
|
765
803
|
try {
|
|
766
|
-
knownFailure = peekInitFailure(sessionId);
|
|
804
|
+
knownFailure = peekInitFailure(sessionId, resolvePrincipal(req));
|
|
767
805
|
}
|
|
768
806
|
catch {
|
|
769
807
|
knownFailure = undefined;
|
|
@@ -780,6 +818,12 @@ advertisedUrl) {
|
|
|
780
818
|
});
|
|
781
819
|
return;
|
|
782
820
|
}
|
|
821
|
+
// #529: the idle clock is refreshed only for a session this route will serve,
|
|
822
|
+
// after the ownership check and the transport lookup, in the same order as
|
|
823
|
+
// POST. Touching before the lookup let a request that was about to be
|
|
824
|
+
// answered "Transport not ready" (a pool entry whose transport is gone, or
|
|
825
|
+
// one still initializing) keep that session alive.
|
|
826
|
+
connectionPool.touch(sessionId); // Refresh the pool's idle clock (#167)
|
|
783
827
|
await transport.handleRequest(req, res);
|
|
784
828
|
});
|
|
785
829
|
// Quick GET server-info probe used by our test harness (testMcpClient.ts, the
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import adapter from '../lib/actual-adapter.js';
|
|
3
3
|
import { CommonSchemas } from '../lib/schemas/common.js';
|
|
4
|
+
import { withLogMessage } from '../lib/errors.js';
|
|
4
5
|
const InputSchema = z.object({
|
|
5
6
|
name: CommonSchemas.name,
|
|
6
7
|
group_id: CommonSchemas.categoryGroupId,
|
|
@@ -36,7 +37,8 @@ const tool = {
|
|
|
36
37
|
throw new Error(`Failed to create category: Invalid or missing group_id. Use actual_category_groups_get to get available category groups first.`);
|
|
37
38
|
}
|
|
38
39
|
if (errorMessage.includes('already exists') || errorMessage.includes('duplicate')) {
|
|
39
|
-
|
|
40
|
+
// The client needs the name; the log gets content-free text (#559).
|
|
41
|
+
throw withLogMessage(new Error(`Failed to create category: A category named "${input.name}" already exists in this group.`), 'category name already exists in this group');
|
|
40
42
|
}
|
|
41
43
|
throw new Error(`Failed to create category: ${errorMessage}`);
|
|
42
44
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import adapter from '../lib/actual-adapter.js';
|
|
3
3
|
import { validateQueryShape } from '../lib/query-validator.js';
|
|
4
|
+
import { errorLogFields, withLogMessage } from '../lib/errors.js';
|
|
4
5
|
const InputSchema = z.object({
|
|
5
6
|
query: z.string().min(1).describe('ActualQL query string to execute'),
|
|
6
7
|
});
|
|
@@ -27,7 +28,8 @@ Tables:
|
|
|
27
28
|
const input = InputSchema.parse(args || {});
|
|
28
29
|
// Detect and reject GraphQL-like syntax with nested objects
|
|
29
30
|
if (input.query.trim().startsWith('query ') && input.query.includes('{') && input.query.includes('}')) {
|
|
30
|
-
|
|
31
|
+
// The client sees its own query echoed; the log gets content-free text (#559).
|
|
32
|
+
throw withLogMessage(new Error(`GraphQL syntax is not fully supported. Please use SQL instead.\n\nExample: SELECT id, date, amount, payee.name, category.name FROM transactions ORDER BY date DESC LIMIT 5\n\nYour query attempted: ${input.query.substring(0, 100)}...`), 'GraphQL-style query rejected');
|
|
31
33
|
}
|
|
32
34
|
// Read-only shape gate (#162): reject writes / schema changes / stacked
|
|
33
35
|
// statements before the query reaches the q() builder. Throws on violation.
|
|
@@ -38,21 +40,24 @@ Tables:
|
|
|
38
40
|
catch (error) {
|
|
39
41
|
// Provide helpful error messages
|
|
40
42
|
const errorMessage = error?.message || String(error);
|
|
43
|
+
// Each rewrap keeps the original's log-safe text (#559), e.g. the GraphQL rejection above.
|
|
44
|
+
const logSafe = errorLogFields(error).logMessage;
|
|
45
|
+
const carry = (e) => (logSafe ? withLogMessage(e, logSafe) : e);
|
|
41
46
|
// Check if error is about payee_name, category_name, account_name
|
|
42
47
|
if (errorMessage.includes('payee_name') || errorMessage.includes('category_name') || errorMessage.includes('account_name')) {
|
|
43
|
-
throw new Error(`Field name error: Use dot notation for joins.\n• Use payee.name (NOT payee_name)\n• Use category.name (NOT category_name)\n• Use account.name (NOT account_name)\n\nExample: SELECT id, date, amount, payee.name FROM transactions LIMIT 5\n\nOriginal error: ${errorMessage}`);
|
|
48
|
+
throw carry(new Error(`Field name error: Use dot notation for joins.\n• Use payee.name (NOT payee_name)\n• Use category.name (NOT category_name)\n• Use account.name (NOT account_name)\n\nExample: SELECT id, date, amount, payee.name FROM transactions LIMIT 5\n\nOriginal error: ${errorMessage}`));
|
|
44
49
|
}
|
|
45
50
|
if (errorMessage.includes('does not exist in the schema')) {
|
|
46
|
-
throw new Error(`Invalid table or field name. Available tables: transactions, accounts, categories, payees, category_groups, schedules, rules. Use dot notation for joins (e.g., category.name). Original error: ${errorMessage}`);
|
|
51
|
+
throw carry(new Error(`Invalid table or field name. Available tables: transactions, accounts, categories, payees, category_groups, schedules, rules. Use dot notation for joins (e.g., category.name). Original error: ${errorMessage}`));
|
|
47
52
|
}
|
|
48
53
|
else if (errorMessage.includes('ActualQL query builder not available')) {
|
|
49
|
-
throw new Error('ActualQL query builder is not available. The Actual Budget API may not be properly initialized.');
|
|
54
|
+
throw carry(new Error('ActualQL query builder is not available. The Actual Budget API may not be properly initialized.'));
|
|
50
55
|
}
|
|
51
56
|
else if (errorMessage.includes('parse') || errorMessage.includes('syntax')) {
|
|
52
|
-
throw new Error(`Query syntax error: ${errorMessage}\n\nRecommended: Use SQL syntax\nExample: SELECT * FROM transactions ORDER BY date DESC LIMIT 5\n\nSee the tool description for the supported WHERE operators and join syntax.`);
|
|
57
|
+
throw carry(new Error(`Query syntax error: ${errorMessage}\n\nRecommended: Use SQL syntax\nExample: SELECT * FROM transactions ORDER BY date DESC LIMIT 5\n\nSee the tool description for the supported WHERE operators and join syntax.`));
|
|
53
58
|
}
|
|
54
59
|
else {
|
|
55
|
-
throw new Error(`Query execution failed: ${errorMessage}`);
|
|
60
|
+
throw carry(new Error(`Query execution failed: ${errorMessage}`));
|
|
56
61
|
}
|
|
57
62
|
}
|
|
58
63
|
},
|
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.40",
|
|
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/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",
|
|
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/budget_acl_parse.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",
|