actual-mcp-server 0.22.47 → 0.22.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/src/actualConnection.js +8 -8
- package/dist/src/auth/static-tokens.js +167 -0
- package/dist/src/config.js +27 -0
- package/dist/src/index.js +7 -2
- package/dist/src/lib/ActualConnectionPool.js +6 -12
- package/dist/src/lib/actual-adapter.js +115 -19
- package/dist/src/lib/apiLogin.js +75 -0
- package/dist/src/lib/apiState.js +28 -1
- package/dist/src/lib/authPosture.js +3 -2
- package/dist/src/lib/config-registry.js +2 -1
- package/dist/src/logger.js +3 -1
- package/dist/src/server/httpServer.js +53 -23
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -516,6 +516,8 @@ All configuration is via environment variables. Copy `.env.example` to `.env` to
|
|
|
516
516
|
| **Security & Authentication** ||||
|
|
517
517
|
| `AUTH_PROVIDER` | `none` | No | Auth mode: `none` (static Bearer) or `oidc` (JWKS-validated JWT) |
|
|
518
518
|
| `MCP_SSE_AUTHORIZATION` | _(none)_ | No | Static Bearer token (`AUTH_PROVIDER=none`; highly recommended in production) |
|
|
519
|
+
| `MCP_STATIC_TOKENS` | _(none)_ | No | Several static Bearer tokens, one per client, each with its own principal and budget allow-list (#572). JSON keyed by principal: `{"alice":{"tokenEnv":"MCP_TOKEN_ALICE","budgets":["Home"]}}`. Budgets are registry names. Can coexist with `MCP_SSE_AUTHORIZATION` (which stays unrestricted); refused with `AUTH_PROVIDER=oidc`. See [Security](docs/SECURITY_AND_PRIVACY.md) |
|
|
520
|
+
| `MCP_TOKEN_*` | _(none)_ | If named | The token values `MCP_STATIC_TOKENS` points at, one variable per principal (`MCP_TOKEN_ALICE`). At least 32 characters (`openssl rand -hex 32`), unique, RFC 6750 characters only; a bad one refuses to start |
|
|
519
521
|
| `MCP_ALLOW_UNAUTHENTICATED` | `false` | No | Opt-out for required-by-default HTTP auth (#242). On a non-loopback bind with no token and no OIDC the server refuses to start; set to `true` to run open deliberately (e.g. behind your own proxy) |
|
|
520
522
|
| `OIDC_ISSUER` | _(none)_ | If OIDC | OIDC issuer URL (e.g., `https://sso.example.com`) |
|
|
521
523
|
| `OIDC_ALLOW_INSECURE_ISSUER` | `false` | No | Allow a plaintext (http) OIDC issuer on a trusted network (#244). Off by default (http issuer refused at startup); set `true` only for local/LAN testing |
|
|
@@ -836,7 +838,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
|
|
|
836
838
|
|
|
837
839
|
---
|
|
838
840
|
|
|
839
|
-
**Version:** 0.22.
|
|
841
|
+
**Version:** 0.22.50 | **Tool Count:** 84 (verified LibreChat-compatible)
|
|
840
842
|
|
|
841
843
|
## Sponsor
|
|
842
844
|
|
|
@@ -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) {
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// src/auth/static-tokens.ts
|
|
2
|
+
//
|
|
3
|
+
// #572: several static Bearer tokens, each bound to its own principal and budget
|
|
4
|
+
// allow-list (MCP_STATIC_TOKENS). Proposed and first implemented by @jospaquim in
|
|
5
|
+
// PR #569; reimplemented here on develop.
|
|
6
|
+
//
|
|
7
|
+
// Why: with the single MCP_SSE_AUTHORIZATION token every caller is the one
|
|
8
|
+
// principal 'static-bearer', so several MCP clients on one server share the #189
|
|
9
|
+
// per-principal budget preference and have no budget isolation without OIDC.
|
|
10
|
+
// Here each client presents its own token, which resolves to a principal (keys
|
|
11
|
+
// the #189 preference and session ownership) and to the request's allowedBudgets,
|
|
12
|
+
// enforced by the SAME chokepoints OIDC uses (_enforceBudgetAcl, switchBudget,
|
|
13
|
+
// the #570 listing and import guards). One enforcement path, two ways to fill it.
|
|
14
|
+
//
|
|
15
|
+
// Format (JSON object keyed by principal; budgets are registry NAMES):
|
|
16
|
+
// MCP_STATIC_TOKENS={"alice":{"tokenEnv":"MCP_TOKEN_ALICE","budgets":["Home"]}}
|
|
17
|
+
// The token itself lives in the variable `tokenEnv` names, so the JSON can sit in
|
|
18
|
+
// a compose file while the secrets stay in the secret store.
|
|
19
|
+
//
|
|
20
|
+
// WHY THIS MODULE IMPORTS ONLY node:crypto. config.ts calls parseStaticTokensConfig
|
|
21
|
+
// at config-parse time (the budget-acl-parse.ts pattern, #551): importing the
|
|
22
|
+
// logger, config or the adapter from here would create an ESM cycle. It throws and
|
|
23
|
+
// never logs.
|
|
24
|
+
//
|
|
25
|
+
// ERROR-TEXT CONTRACT. A thrown message names principals, variable names and
|
|
26
|
+
// budget names only, never a token value.
|
|
27
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
28
|
+
/** Principal reserved for the legacy MCP_SSE_AUTHORIZATION token. */
|
|
29
|
+
export const LEGACY_STATIC_PRINCIPAL = 'static-bearer';
|
|
30
|
+
/** Minimum token length: `openssl rand -hex 16` produces exactly this. */
|
|
31
|
+
export const MIN_TOKEN_LENGTH = 32;
|
|
32
|
+
const PRINCIPAL_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
33
|
+
// Restricted to one family so a principal cannot point at ACTUAL_PASSWORD or any
|
|
34
|
+
// other secret, and so logger.ts can scrub every value of the family.
|
|
35
|
+
const TOKEN_ENV_RE = /^MCP_TOKEN_[A-Z0-9_]+$/;
|
|
36
|
+
// RFC 6750 b64token: '=' only as trailing padding, so whitespace and other
|
|
37
|
+
// characters a header cannot carry intact are refused at startup.
|
|
38
|
+
const B64TOKEN_RE = /^[A-Za-z0-9._~+/-]+=*$/;
|
|
39
|
+
/** True when the raw value is set (unset and blank both mean "feature off"). */
|
|
40
|
+
export function staticTokensConfigured(raw) {
|
|
41
|
+
return raw !== undefined && raw.trim() !== '';
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Shape check of MCP_STATIC_TOKENS, run by config.ts at startup on both
|
|
45
|
+
* transports. Returns {} when unset or blank. Touches no environment and no
|
|
46
|
+
* registry: that is resolveStaticTokens, HTTP only.
|
|
47
|
+
*/
|
|
48
|
+
export function parseStaticTokensConfig(raw) {
|
|
49
|
+
if (!staticTokensConfigured(raw))
|
|
50
|
+
return {};
|
|
51
|
+
let parsed;
|
|
52
|
+
try {
|
|
53
|
+
parsed = JSON.parse(raw);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
throw new Error('MCP_STATIC_TOKENS is not valid JSON');
|
|
57
|
+
}
|
|
58
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
59
|
+
throw new Error('MCP_STATIC_TOKENS must be a JSON object keyed by principal');
|
|
60
|
+
}
|
|
61
|
+
const entries = Object.entries(parsed);
|
|
62
|
+
if (entries.length === 0) {
|
|
63
|
+
throw new Error('MCP_STATIC_TOKENS is set but defines no principals');
|
|
64
|
+
}
|
|
65
|
+
const out = {};
|
|
66
|
+
for (const [principal, value] of entries) {
|
|
67
|
+
if (!PRINCIPAL_RE.test(principal)) {
|
|
68
|
+
throw new Error(`MCP_STATIC_TOKENS: invalid principal name "${principal}" (letters, digits, . _ -; at most 64)`);
|
|
69
|
+
}
|
|
70
|
+
if (principal === LEGACY_STATIC_PRINCIPAL) {
|
|
71
|
+
throw new Error(`MCP_STATIC_TOKENS: principal "${LEGACY_STATIC_PRINCIPAL}" is reserved for MCP_SSE_AUTHORIZATION`);
|
|
72
|
+
}
|
|
73
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
74
|
+
throw new Error(`MCP_STATIC_TOKENS: entry for "${principal}" must be an object {"tokenEnv", "budgets"}`);
|
|
75
|
+
}
|
|
76
|
+
const { tokenEnv, budgets } = value;
|
|
77
|
+
if (typeof tokenEnv !== 'string' || !TOKEN_ENV_RE.test(tokenEnv)) {
|
|
78
|
+
throw new Error(`MCP_STATIC_TOKENS: "${principal}".tokenEnv must be a variable name starting with MCP_TOKEN_ (A to Z, 0 to 9, _)`);
|
|
79
|
+
}
|
|
80
|
+
if (!Array.isArray(budgets) || budgets.length === 0 || !budgets.every((b) => typeof b === 'string' && b.trim() !== '')) {
|
|
81
|
+
throw new Error(`MCP_STATIC_TOKENS: "${principal}".budgets must be a non-empty array of budget names`);
|
|
82
|
+
}
|
|
83
|
+
out[principal] = { tokenEnv, budgets: [...budgets] };
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
function digestOf(token) {
|
|
88
|
+
return createHash('sha256').update(token, 'utf8').digest();
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Resolve MCP_STATIC_TOKENS against the environment and the budget registry, and
|
|
92
|
+
* build the matcher's entries: one per static principal, plus the legacy token
|
|
93
|
+
* when set. Called by startHttpServer before it binds, so a refusal stops startup
|
|
94
|
+
* the way an unreadable TLS key does. Returns [] when neither token source is set.
|
|
95
|
+
*/
|
|
96
|
+
export function resolveStaticTokens(raw, env, registry, legacyToken) {
|
|
97
|
+
const parsed = parseStaticTokensConfig(raw);
|
|
98
|
+
const byName = new Map();
|
|
99
|
+
for (const b of registry)
|
|
100
|
+
byName.set(b.name.toLowerCase(), b);
|
|
101
|
+
const entries = [];
|
|
102
|
+
// Uniqueness is checked on digests: equal digests are equal tokens, and no
|
|
103
|
+
// token value has to be held to compare them.
|
|
104
|
+
const seen = new Map();
|
|
105
|
+
if (legacyToken)
|
|
106
|
+
seen.set(digestOf(legacyToken).toString('hex'), 'MCP_SSE_AUTHORIZATION');
|
|
107
|
+
for (const [principal, { tokenEnv, budgets }] of Object.entries(parsed)) {
|
|
108
|
+
const token = env[tokenEnv];
|
|
109
|
+
if (token === undefined || token === '') {
|
|
110
|
+
throw new Error(`MCP_STATIC_TOKENS: "${principal}".tokenEnv names ${tokenEnv}, which is unset or empty`);
|
|
111
|
+
}
|
|
112
|
+
if (token.length < MIN_TOKEN_LENGTH) {
|
|
113
|
+
throw new Error(`MCP_STATIC_TOKENS: the token in ${tokenEnv} is shorter than ${MIN_TOKEN_LENGTH} characters`);
|
|
114
|
+
}
|
|
115
|
+
if (!B64TOKEN_RE.test(token)) {
|
|
116
|
+
throw new Error(`MCP_STATIC_TOKENS: the token in ${tokenEnv} contains characters outside the RFC 6750 token set ` +
|
|
117
|
+
'(letters, digits, . _ ~ + / -, and = only as trailing padding)');
|
|
118
|
+
}
|
|
119
|
+
const allowedBudgets = [];
|
|
120
|
+
for (const name of budgets) {
|
|
121
|
+
const found = byName.get(name.toLowerCase());
|
|
122
|
+
// A "*" sync id would read as "unrestricted" to every ACL check, so it is
|
|
123
|
+
// refused like an unknown name: a static principal is always restricted, and
|
|
124
|
+
// the unrestricted case is the legacy token.
|
|
125
|
+
if (!found || found.syncId === '*') {
|
|
126
|
+
const available = [...byName.values()].map((b) => `"${b.name}"`).join(', ');
|
|
127
|
+
throw new Error(`MCP_STATIC_TOKENS: "${principal}" references unknown budget "${name}". Available: ${available}`);
|
|
128
|
+
}
|
|
129
|
+
if (!allowedBudgets.includes(found.syncId))
|
|
130
|
+
allowedBudgets.push(found.syncId);
|
|
131
|
+
}
|
|
132
|
+
const digest = digestOf(token);
|
|
133
|
+
const other = seen.get(digest.toString('hex'));
|
|
134
|
+
if (other) {
|
|
135
|
+
throw new Error(`MCP_STATIC_TOKENS: the token in ${tokenEnv} is not unique (also used by ${other})`);
|
|
136
|
+
}
|
|
137
|
+
seen.set(digest.toString('hex'), tokenEnv);
|
|
138
|
+
entries.push({ principal, allowedBudgets, digest });
|
|
139
|
+
}
|
|
140
|
+
if (legacyToken)
|
|
141
|
+
entries.push({ principal: LEGACY_STATIC_PRINCIPAL, digest: digestOf(legacyToken) });
|
|
142
|
+
return entries;
|
|
143
|
+
}
|
|
144
|
+
function matchWith(presented, entries, compare) {
|
|
145
|
+
const digest = digestOf(presented);
|
|
146
|
+
let match;
|
|
147
|
+
// Every entry is compared, with no early exit, so timing reveals neither which
|
|
148
|
+
// principal matched nor how many exist. Digests are all 32 bytes, so there is
|
|
149
|
+
// no length short-circuit either (the pre-#572 matcher had one).
|
|
150
|
+
for (const e of entries) {
|
|
151
|
+
if (compare(digest, e.digest) && !match)
|
|
152
|
+
match = e;
|
|
153
|
+
}
|
|
154
|
+
return match;
|
|
155
|
+
}
|
|
156
|
+
/** Constant-time lookup of a presented Bearer token. Used for static AND legacy tokens. */
|
|
157
|
+
export function matchBearerToken(presented, entries) {
|
|
158
|
+
return matchWith(presented, entries, timingSafeEqual);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Test seam: the matcher with an injected compare, so a test can count compares.
|
|
162
|
+
* Separate from matchBearerToken so no production caller can pass a compare that
|
|
163
|
+
* is not constant time.
|
|
164
|
+
*/
|
|
165
|
+
export function _matchBearerTokenWithCompareForTests(presented, entries, compare) {
|
|
166
|
+
return matchWith(presented, entries, compare);
|
|
167
|
+
}
|
package/dist/src/config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { parseIdentityMap } from './auth/identity-map.js';
|
|
3
3
|
import { parseBudgetAcl } from './auth/budget-acl-parse.js';
|
|
4
|
+
import { parseStaticTokensConfig, staticTokensConfigured } from './auth/static-tokens.js';
|
|
4
5
|
// #466: the byte count body-parser will ACTUALLY enforce for MCP_HTTP_BODY_LIMIT, or
|
|
5
6
|
// null when the value must be refused. Mirrors `bytes.parse` (what body-parser uses)
|
|
6
7
|
// without importing that transitive package: floor(number * unit), units b and kb
|
|
@@ -101,6 +102,24 @@ export const configSchema = z.object({
|
|
|
101
102
|
MCP_BRIDGE_PORT: z.string().default('3600'),
|
|
102
103
|
MCP_TRANSPORT_MODE: z.enum(['--http']).default('--http'),
|
|
103
104
|
MCP_SSE_AUTHORIZATION: z.string().optional(),
|
|
105
|
+
// #572: several static Bearer tokens, each with its own principal and budget
|
|
106
|
+
// allow-list, as a JSON object keyed by principal:
|
|
107
|
+
// {"alice":{"tokenEnv":"MCP_TOKEN_ALICE","budgets":["Home"]}}
|
|
108
|
+
// Only the SHAPE is checked here (both transports). The tokens themselves
|
|
109
|
+
// (MCP_TOKEN_*) and the budget names are resolved by the HTTP server before it
|
|
110
|
+
// binds (src/auth/static-tokens.ts). Unset or blank means the feature is off.
|
|
111
|
+
MCP_STATIC_TOKENS: z
|
|
112
|
+
.string()
|
|
113
|
+
.optional()
|
|
114
|
+
.superRefine((v, ctx) => {
|
|
115
|
+
try {
|
|
116
|
+
parseStaticTokensConfig(v);
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
// Safe by contract: names principals and variables, never a token value.
|
|
120
|
+
ctx.addIssue({ code: 'custom', message: err instanceof Error ? err.message : String(err) });
|
|
121
|
+
}
|
|
122
|
+
}),
|
|
104
123
|
// #242: explicit opt-out for the required-by-default HTTP auth gate. When auth
|
|
105
124
|
// is unconfigured AND the bind is non-loopback, the server refuses to start
|
|
106
125
|
// unless this is the exact string 'true'. Strict parse (only 'true' enables)
|
|
@@ -318,6 +337,14 @@ export const configSchema = z.object({
|
|
|
318
337
|
'unrelated userId, so matching sub denies every user. Use AUTH_BUDGET_ACL_CLAIM=auto (the default, ' +
|
|
319
338
|
'which mirrors that precedence), or pin a claim that your IdP also used to populate Actual user_name. ' +
|
|
320
339
|
'See #343.',
|
|
340
|
+
})
|
|
341
|
+
// #572: static tokens are their own authentication mode. Under OIDC the verified
|
|
342
|
+
// JWT decides the principal and AUTH_BUDGET_ACL the budgets, so a static token
|
|
343
|
+
// would either be ignored or open a second door; refuse the pairing.
|
|
344
|
+
.refine((cfg) => !(cfg.AUTH_PROVIDER === 'oidc' && staticTokensConfigured(cfg.MCP_STATIC_TOKENS)), {
|
|
345
|
+
path: ['MCP_STATIC_TOKENS'],
|
|
346
|
+
message: 'MCP_STATIC_TOKENS cannot be combined with AUTH_PROVIDER=oidc: under OIDC the verified token decides ' +
|
|
347
|
+
'the principal and AUTH_BUDGET_ACL decides its budgets. Unset one of them (#572).',
|
|
321
348
|
})
|
|
322
349
|
// When native TLS is enabled, both the cert and key paths must be provided.
|
|
323
350
|
// MCP_ENABLE_HTTPS is transformed to a boolean above, so this object-level
|
package/dist/src/index.js
CHANGED
|
@@ -303,7 +303,9 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
|
|
|
303
303
|
if (useHttp || useTestMcpClient) {
|
|
304
304
|
const decision = enforceHttpAuthPosture({
|
|
305
305
|
bindHost,
|
|
306
|
-
|
|
306
|
+
// #572: MCP_STATIC_TOKENS is authentication too. A set-but-unresolvable value
|
|
307
|
+
// still counts here: startHttpServer refuses it before binding.
|
|
308
|
+
hasStaticToken: !!process.env.MCP_SSE_AUTHORIZATION || !!process.env.MCP_STATIC_TOKENS?.trim(),
|
|
307
309
|
oidcEnabled: process.env.AUTH_PROVIDER === 'oidc',
|
|
308
310
|
allowUnauthenticated: process.env.MCP_ALLOW_UNAUTHENTICATED === 'true',
|
|
309
311
|
});
|
|
@@ -379,7 +381,10 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
|
|
|
379
381
|
}
|
|
380
382
|
}
|
|
381
383
|
main().catch((err) => {
|
|
382
|
-
|
|
384
|
+
// #572: the message goes INTO the format string. Passed as a second argument, winston
|
|
385
|
+
// treats a string as metadata and printed it one character per line ("0": "M", ...),
|
|
386
|
+
// so an operator never saw why startup failed.
|
|
387
|
+
logger.error(`Failed to start Actual MCP bridge: ${err?.message || String(err)}`);
|
|
383
388
|
process.exit(1);
|
|
384
389
|
});
|
|
385
390
|
})();
|
|
@@ -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
|
/**
|
|
@@ -106,7 +108,42 @@ function getActiveBudgetConfig() {
|
|
|
106
108
|
return restored;
|
|
107
109
|
}
|
|
108
110
|
}
|
|
109
|
-
|
|
111
|
+
// #572: a restricted caller with no selection and no restorable preference starts
|
|
112
|
+
// on its FIRST PERMITTED budget in registry order (the order the operator controls
|
|
113
|
+
// and every listing shows; the allow-list is a set), instead of the default it may
|
|
114
|
+
// not use. Keyed on the list, not the auth mode, so OIDC restricted principals get
|
|
115
|
+
// it too: before #572 one whose list excluded the default got "Budget ACL:" on every
|
|
116
|
+
// call until it switched. With no restricting list, or nothing in the registry
|
|
117
|
+
// permitted, this is the default as before, and _enforceBudgetAcl then refuses.
|
|
118
|
+
// Not memoised: a later switch writes the session slot, and the next call
|
|
119
|
+
// recomputes the same answer from the same list.
|
|
120
|
+
const values = [...budgetRegistry.values()];
|
|
121
|
+
const restricting = restrictingAllowList(store?.allowedBudgets);
|
|
122
|
+
if (restricting) {
|
|
123
|
+
const permitted = values.find((b) => restricting.includes(b.syncId));
|
|
124
|
+
if (permitted)
|
|
125
|
+
return permitted;
|
|
126
|
+
}
|
|
127
|
+
return values[0];
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* #571: the budget a NEW session should open on, resolved before its session id is live.
|
|
131
|
+
*
|
|
132
|
+
* httpServer opens the pool connection from `onsessioninitialized`, which runs inside the
|
|
133
|
+
* initialize request's context: principal and ACL are there, the session id is not yet. Without
|
|
134
|
+
* the id, getActiveBudgetConfig cannot restore the #189 preference, so the connection logged in
|
|
135
|
+
* to the DEFAULT server and downloaded the default budget even when the session's budget lives
|
|
136
|
+
* on another server, and the session's first call then asked the default server for it.
|
|
137
|
+
*
|
|
138
|
+
* Runs getActiveBudgetConfig with the id added to the CURRENT context, so the ACL and principal
|
|
139
|
+
* of the initialize request still decide. Its memoisation goes through setSessionBudget, which
|
|
140
|
+
* the isSessionLive guard keeps from recording a session that already ended.
|
|
141
|
+
*
|
|
142
|
+
* Shape credit: @jospaquim (PR #569).
|
|
143
|
+
*/
|
|
144
|
+
export function initialBudgetForSession(sessionId) {
|
|
145
|
+
const store = requestContext.getStore() ?? {};
|
|
146
|
+
return requestContext.run({ ...store, sessionId }, () => getActiveBudgetConfig());
|
|
110
147
|
}
|
|
111
148
|
/**
|
|
112
149
|
* Global API session mutex.
|
|
@@ -172,7 +209,10 @@ async function ensureLoadedBudgetMatchesSession() {
|
|
|
172
209
|
}
|
|
173
210
|
const wanted = getActiveBudgetConfig();
|
|
174
211
|
const loaded = getLoadedBudgetSyncId();
|
|
175
|
-
|
|
212
|
+
// #571: the sync id alone is not a match. The same sync id can exist on two servers (a budget
|
|
213
|
+
// copied between them), and a match against the wrong server would run this session's
|
|
214
|
+
// operation on the other server's copy.
|
|
215
|
+
if (loaded === wanted.syncId && isApiLoggedInTo(loginOf(wanted)))
|
|
176
216
|
return;
|
|
177
217
|
// Deliberately a WARN: reaching here means two sessions are on different budgets and this
|
|
178
218
|
// one would otherwise have operated on the other's data.
|
|
@@ -185,7 +225,11 @@ async function ensureLoadedBudgetMatchesSession() {
|
|
|
185
225
|
// per operation, a drain can now change budgets MID-BATCH, and the single trailing api.sync()
|
|
186
226
|
// covers only the last one. Without this, an earlier session's write is applied locally and
|
|
187
227
|
// silently waits for the next load of that budget to propagate, which an ephemeral data dir
|
|
188
|
-
// would lose entirely.
|
|
228
|
+
// would lose entirely. Runs on a cross-login re-selection too, while the old login is still
|
|
229
|
+
// current: initApiLoggedIn's close deliberately does not sync (see there for the out-of-sync
|
|
230
|
+
// failure a syncing close caused at session open), so this is the only sync the outgoing budget
|
|
231
|
+
// gets. It is still a sync of a copy that may be stale if another process wrote the data dir:
|
|
232
|
+
// #575 tracks that hazard, which every sync here and after writes shares.
|
|
189
233
|
if (loaded) {
|
|
190
234
|
try {
|
|
191
235
|
await withOpTimeout(() => api.sync(), 'sync');
|
|
@@ -197,7 +241,45 @@ async function ensureLoadedBudgetMatchesSession() {
|
|
|
197
241
|
});
|
|
198
242
|
}
|
|
199
243
|
}
|
|
200
|
-
await
|
|
244
|
+
await loadBudgetOnItsServer(wanted);
|
|
245
|
+
}
|
|
246
|
+
function loginOf(budget) {
|
|
247
|
+
return { serverUrl: budget.serverUrl, password: budget.password || '' };
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* #571: load a budget over a login to the server that HOLDS it. MUST be called inside the api lock.
|
|
251
|
+
*
|
|
252
|
+
* Every re-selection over a live singleton goes through here (the #390 precondition, which the
|
|
253
|
+
* kept-alive stdio singleton of #419 reaches too, and switchBudget's same-auth fast path). Both
|
|
254
|
+
* used to download over whatever login the singleton already had, which is right on a single
|
|
255
|
+
* server and wrong as soon as another session has logged the singleton in to a different one:
|
|
256
|
+
* reproduced live as a session on server X failing every call with "Budget not found" for as
|
|
257
|
+
* long as a session on server Y was active.
|
|
258
|
+
*
|
|
259
|
+
* Same server: no new login, so the #127 auth burst #134 removed does not come back.
|
|
260
|
+
*
|
|
261
|
+
* Different or unknown server: log in to the budget's own server with the budget's own password
|
|
262
|
+
* through initApiLoggedIn, which first clears the records and closes the outgoing budget WITHOUT
|
|
263
|
+
* syncing it, then download. The #390 precondition syncs the outgoing budget before calling here;
|
|
264
|
+
* switchBudget's same-auth fast path does not, so a pending change there syncs at that budget's
|
|
265
|
+
* next load.
|
|
266
|
+
*
|
|
267
|
+
* Failure: initApiLoggedIn clears the loaded-budget and login records before anything else, so
|
|
268
|
+
* any failure leaves both unknown, the next operation of ANY session re-selects, and the error
|
|
269
|
+
* reaches the caller as an ordinary tool error. The live flag is deliberately NOT cleared:
|
|
270
|
+
* clearing it would send every session through the legacy per-operation init (the #127 burst)
|
|
271
|
+
* until a new session happened to open, while the cleared records already force the one re-login
|
|
272
|
+
* that recovery needs.
|
|
273
|
+
*
|
|
274
|
+
* Never takes the api lock itself: the lock is not reentrant and every caller already holds it.
|
|
275
|
+
*/
|
|
276
|
+
async function loadBudgetOnItsServer(budget) {
|
|
277
|
+
const login = loginOf(budget);
|
|
278
|
+
if (!isApiLoggedInTo(login)) {
|
|
279
|
+
logger.info(`[ADAPTER] logging the api in to ${budget.serverUrl} before loading "${budget.name}"`);
|
|
280
|
+
await withAuthRetry(() => initApiLoggedIn(config.MCP_BRIDGE_DATA_DIR, login, 'init'));
|
|
281
|
+
}
|
|
282
|
+
await loadBudgetTracked(budget.syncId, budget.encryptionPassword);
|
|
201
283
|
}
|
|
202
284
|
// Per-op timeout (#270) lives in ./opTimeout.ts so ActualConnectionPool can bound
|
|
203
285
|
// its own session-open init/download without a circular import back into this
|
|
@@ -381,10 +463,12 @@ function abortBatchOnInfrastructureError(error, ctx) {
|
|
|
381
463
|
function _enforceBudgetAcl(toolName) {
|
|
382
464
|
const sessionId = requestContext.getStore()?.sessionId;
|
|
383
465
|
// Trusted-local short-circuit. stdio and startup health checks run with no
|
|
384
|
-
// sessionId; in non-OIDC modes those are by-construction
|
|
385
|
-
// user, local process).
|
|
386
|
-
//
|
|
387
|
-
|
|
466
|
+
// sessionId AND no allow-list; in non-OIDC modes those are by-construction
|
|
467
|
+
// trusted (single user, local process). #572: a context that CARRIES a list is
|
|
468
|
+
// always checked, session or not (the HTTP initialize context has a static
|
|
469
|
+
// principal's list and no session id yet), so the skip is narrowed to contexts
|
|
470
|
+
// with no list.
|
|
471
|
+
if (!sessionId && config.AUTH_PROVIDER !== 'oidc' && !requestContext.getStore()?.allowedBudgets) {
|
|
388
472
|
return;
|
|
389
473
|
}
|
|
390
474
|
const allowedBudgets = restrictedAllowedBudgets(toolName);
|
|
@@ -436,6 +520,17 @@ function restrictedAllowedBudgets(toolName) {
|
|
|
436
520
|
throw new Error('Budget ACL: no allowedBudgets in request context. ' +
|
|
437
521
|
'This request bypassed the budget-acl middleware. Refusing for safety. See #156.');
|
|
438
522
|
}
|
|
523
|
+
return restrictingAllowList(allowedBudgets);
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* #572: THE allow-list predicate: the list when it restricts, else `undefined`.
|
|
527
|
+
* A missing list and a list containing `"*"` do not restrict. An EMPTY list does:
|
|
528
|
+
* it is the deny state (budget-acl.ts gives [] to a principal with no access), so it
|
|
529
|
+
* restricts to nothing. Used by restrictedAllowedBudgets, switchBudget, the
|
|
530
|
+
* _enforceBudgetAcl skip and the getActiveBudgetConfig fallback, so they cannot
|
|
531
|
+
* disagree about who is restricted.
|
|
532
|
+
*/
|
|
533
|
+
function restrictingAllowList(allowedBudgets) {
|
|
439
534
|
if (!allowedBudgets || allowedBudgets.includes('*'))
|
|
440
535
|
return undefined;
|
|
441
536
|
return allowedBudgets;
|
|
@@ -741,11 +836,7 @@ async function initActualApiForOperation() {
|
|
|
741
836
|
// withAuthRetry does not retry it. Wrapping per-attempt (not the whole retry
|
|
742
837
|
// loop) means legitimate #127 auth-rate-limit backoff (up to ~25s) is not
|
|
743
838
|
// counted against ACTUAL_OP_TIMEOUT_MS.
|
|
744
|
-
await withAuthRetry(() =>
|
|
745
|
-
dataDir: DATA_DIR,
|
|
746
|
-
serverURL: budget.serverUrl,
|
|
747
|
-
password: budget.password || '',
|
|
748
|
-
}), 'init'));
|
|
839
|
+
await withAuthRetry(() => initApiLoggedIn(DATA_DIR, loginOf(budget), 'init'));
|
|
749
840
|
logger.debug('[ADAPTER] Downloading budget');
|
|
750
841
|
// Bound downloadBudget (#270): the legacy stdio path re-downloads on every
|
|
751
842
|
// op, and a stall here was the production hang. On timeout it rejects and
|
|
@@ -3946,8 +4037,11 @@ export async function switchBudget(name) {
|
|
|
3946
4037
|
if (!found)
|
|
3947
4038
|
throw notFound();
|
|
3948
4039
|
// ACL enforcement: the target budget must be in this session's allowedBudgets.
|
|
3949
|
-
// OIDC mode: explicit ACL required.
|
|
3950
|
-
|
|
4040
|
+
// OIDC mode: explicit ACL required. #572: any mode whose request carries a
|
|
4041
|
+
// restricting list (MCP_STATIC_TOKENS) is checked against it too; with no list
|
|
4042
|
+
// outside OIDC the caller is unrestricted (legacy bearer, no-auth).
|
|
4043
|
+
const restricting = restrictingAllowList(allowedBudgets);
|
|
4044
|
+
if (config.AUTH_PROVIDER === 'oidc' || restricting) {
|
|
3951
4045
|
if (!allowedBudgets) {
|
|
3952
4046
|
logger.warn(JSON.stringify({
|
|
3953
4047
|
event: 'acl_denied',
|
|
@@ -3958,7 +4052,7 @@ export async function switchBudget(name) {
|
|
|
3958
4052
|
}));
|
|
3959
4053
|
throw new Error(`Budget ACL: cannot switch to "${found.name}". No allowedBudgets in request context.`);
|
|
3960
4054
|
}
|
|
3961
|
-
if (
|
|
4055
|
+
if (restricting && !restricting.includes(found.syncId)) {
|
|
3962
4056
|
logger.warn(JSON.stringify({
|
|
3963
4057
|
event: 'acl_denied',
|
|
3964
4058
|
attemptedBudget: found.syncId,
|
|
@@ -4005,8 +4099,10 @@ export async function switchBudget(name) {
|
|
|
4005
4099
|
}
|
|
4006
4100
|
else {
|
|
4007
4101
|
await withApiLock(async () => {
|
|
4008
|
-
// Bound the in-place budget reload too (#270).
|
|
4009
|
-
|
|
4102
|
+
// Bound the in-place budget reload too (#270). #571: "same auth" compares against THIS
|
|
4103
|
+
// session's pool entry, but the singleton may be logged in to another session's server by
|
|
4104
|
+
// now, so the load still has to make sure it is talking to this budget's server.
|
|
4105
|
+
await loadBudgetOnItsServer(found);
|
|
4010
4106
|
});
|
|
4011
4107
|
}
|
|
4012
4108
|
connectionPool.updateLoadedSyncId(sessionId, found.syncId);
|
|
@@ -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
|
|
@@ -76,7 +76,8 @@ export function enforceHttpAuthPosture(input, log = logger, exit = process.exit)
|
|
|
76
76
|
const { decision, reason } = evaluateHttpAuthPosture(input);
|
|
77
77
|
if (decision === 'refuse') {
|
|
78
78
|
log.error(`Refusing to start: ${reason}. HTTP auth is required-by-default. ` +
|
|
79
|
-
'Set MCP_SSE_AUTHORIZATION to a strong token (openssl rand -hex 32), or ' +
|
|
79
|
+
'Set MCP_SSE_AUTHORIZATION to a strong token (openssl rand -hex 32), or set ' +
|
|
80
|
+
'MCP_STATIC_TOKENS (one token per client, #572), or ' +
|
|
80
81
|
'set AUTH_PROVIDER=oidc, or bind a loopback host. To deliberately run open ' +
|
|
81
82
|
'(for example behind your own authenticating proxy), set MCP_ALLOW_UNAUTHENTICATED=true.');
|
|
82
83
|
exit(1);
|
|
@@ -85,7 +86,7 @@ export function enforceHttpAuthPosture(input, log = logger, exit = process.exit)
|
|
|
85
86
|
log.warn(`Serving HTTP WITHOUT authentication: ${reason}. Anyone who can reach this ` +
|
|
86
87
|
'port can read and modify your financial data. This is allowed only because ' +
|
|
87
88
|
'MCP_ALLOW_UNAUTHENTICATED=true. Put an authenticating reverse proxy in front, ' +
|
|
88
|
-
'or set MCP_SSE_AUTHORIZATION / AUTH_PROVIDER=oidc.');
|
|
89
|
+
'or set MCP_SSE_AUTHORIZATION / MCP_STATIC_TOKENS / AUTH_PROVIDER=oidc.');
|
|
89
90
|
}
|
|
90
91
|
return decision;
|
|
91
92
|
}
|
|
@@ -67,7 +67,8 @@ export const SCHEMA_VARS_ALSO_READ_RAW = [
|
|
|
67
67
|
* one. `.env.example` documents example members (BUDGET_1_*, BUDGET_2_*, ...); the
|
|
68
68
|
* drift guard collapses any name starting with one of these to the family.
|
|
69
69
|
*/
|
|
70
|
-
|
|
70
|
+
// MCP_TOKEN_ (#572): the per-principal static token values that MCP_STATIC_TOKENS names.
|
|
71
|
+
export const DYNAMIC_ENV_FAMILIES = ['BUDGET_', 'MCP_TOKEN_'];
|
|
71
72
|
/**
|
|
72
73
|
* Variables documented for the operator (e.g. in Docker) but read by the OS or
|
|
73
74
|
* runtime, not by the app. The drift guard allows these in docs without requiring a
|
package/dist/src/logger.js
CHANGED
|
@@ -168,7 +168,9 @@ export function collectSecretValues(env = process.env) {
|
|
|
168
168
|
// length guard avoids masking short/common values that could collide with benign log
|
|
169
169
|
// text (a short secret is still masked by KEY name when under a sensitive field; only
|
|
170
170
|
// the free-text VALUE scrub is skipped for it).
|
|
171
|
-
|
|
171
|
+
// MCP_TOKEN_* (#572) are the static Bearer tokens MCP_STATIC_TOKENS names; MCP_TOKEN_ALICE
|
|
172
|
+
// ends in neither suffix, so the family is matched by prefix.
|
|
173
|
+
if (typeof v === 'string' && v.length >= 8 && (/(PASSWORD|SECRET|AUTHORIZATION|_TOKEN)$/.test(k) || k.startsWith('MCP_TOKEN_'))) {
|
|
172
174
|
out.push(v);
|
|
173
175
|
}
|
|
174
176
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import express from 'express';
|
|
2
|
-
import { randomUUID
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
4
4
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
5
5
|
import { ListToolsRequestSchema, CallToolRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
@@ -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, getBudgetRegistry } 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';
|
|
@@ -23,6 +23,7 @@ import { stripJoseCause } from '../lib/oidc-error-cause.js';
|
|
|
23
23
|
import { buildAcceptedAudiences } from '../lib/oidc-audiences.js';
|
|
24
24
|
import { parseScopeList } from '../lib/oidc-scopes.js';
|
|
25
25
|
import { budgetAclMiddleware } from '../auth/budget-acl.js';
|
|
26
|
+
import { resolveStaticTokens, matchBearerToken, LEGACY_STATIC_PRINCIPAL } from '../auth/static-tokens.js';
|
|
26
27
|
import { runAclPreflight } from '../auth/budget-acl-dynamic.js';
|
|
27
28
|
import * as https from 'node:https';
|
|
28
29
|
import { finished } from 'node:stream';
|
|
@@ -42,14 +43,19 @@ import { createHttpErrorHandler } from './httpErrorHandler.js';
|
|
|
42
43
|
export { classifyInitFailure };
|
|
43
44
|
export { requestContext };
|
|
44
45
|
// Resolve the authenticated principal for the per-principal budget preference
|
|
45
|
-
// (#189). OIDC: the verified JWT subject.
|
|
46
|
-
//
|
|
47
|
-
//
|
|
46
|
+
// (#189). OIDC: the verified JWT subject. A MCP_STATIC_TOKENS token (#572): its
|
|
47
|
+
// own principal, set by authenticateRequest on a match. Legacy static bearer: a
|
|
48
|
+
// single shared identity (all its callers are the same user). Auth-disabled:
|
|
49
|
+
// undefined, so the preference simply no-ops. Never derived from a
|
|
50
|
+
// client-supplied value.
|
|
48
51
|
function resolvePrincipal(req) {
|
|
49
52
|
if (config.AUTH_PROVIDER === 'oidc') {
|
|
50
53
|
return req.auth?.subject;
|
|
51
54
|
}
|
|
52
|
-
|
|
55
|
+
const staticPrincipal = req.staticPrincipal;
|
|
56
|
+
if (staticPrincipal)
|
|
57
|
+
return staticPrincipal;
|
|
58
|
+
return config.MCP_SSE_AUTHORIZATION ? LEGACY_STATIC_PRINCIPAL : undefined;
|
|
53
59
|
}
|
|
54
60
|
export async function startHttpServer(mcp, port, httpPath, capabilities, // was passed by index.ts
|
|
55
61
|
implementedTools, // was passed by index.ts
|
|
@@ -59,6 +65,14 @@ toolSchemas, // was passed by index.ts
|
|
|
59
65
|
version, // server version from package.json
|
|
60
66
|
bindHost = 'localhost', // #239: interface to bind; forwarded to listen()
|
|
61
67
|
advertisedUrl) {
|
|
68
|
+
// #572: resolve MCP_STATIC_TOKENS (token variables, budget names) before anything
|
|
69
|
+
// binds, so a bad entry refuses startup like an unreadable TLS key. The registry is
|
|
70
|
+
// read outside any request context, so it is the full, unfiltered list. config.ts
|
|
71
|
+
// already refused the variable under OIDC, where these entries are never consulted.
|
|
72
|
+
const bearerEntries = config.AUTH_PROVIDER === 'oidc'
|
|
73
|
+
? []
|
|
74
|
+
: resolveStaticTokens(config.MCP_STATIC_TOKENS, process.env, getBudgetRegistry(), config.MCP_SSE_AUTHORIZATION);
|
|
75
|
+
const staticPrincipals = bearerEntries.filter((e) => e.allowedBudgets);
|
|
62
76
|
const app = express();
|
|
63
77
|
// Explicit body-size cap (#168). An oversized payload is rejected with HTTP 413
|
|
64
78
|
// rather than buffered unbounded. Tunable via MCP_HTTP_BODY_LIMIT (default 512kb).
|
|
@@ -336,9 +350,11 @@ advertisedUrl) {
|
|
|
336
350
|
res.status(401).json({ error: 'Unauthorized: OIDC authentication required' });
|
|
337
351
|
return false;
|
|
338
352
|
}
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
|
|
353
|
+
// Static Bearer token mode (default): MCP_SSE_AUTHORIZATION and/or MCP_STATIC_TOKENS
|
|
354
|
+
// (#572). Only when NEITHER is configured is every request allowed. Static tokens are
|
|
355
|
+
// authentication, so MCP_ALLOW_UNAUTHENTICATED (a posture opt-out for the no-auth
|
|
356
|
+
// case) does not relax this.
|
|
357
|
+
if (bearerEntries.length === 0) {
|
|
342
358
|
return true;
|
|
343
359
|
}
|
|
344
360
|
const authHeader = req.headers.authorization;
|
|
@@ -354,20 +370,22 @@ advertisedUrl) {
|
|
|
354
370
|
res.status(401).json({ error: 'Unauthorized: Invalid Authorization header format. Expected "Bearer <token>"' });
|
|
355
371
|
return false;
|
|
356
372
|
}
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
//
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
// via response timing regardless; the secret bytes are not.
|
|
363
|
-
const tokenBuf = Buffer.from(token, 'utf8');
|
|
364
|
-
const expectedBuf = Buffer.from(expected, 'utf8');
|
|
365
|
-
const equal = tokenBuf.length === expectedBuf.length && timingSafeEqual(tokenBuf, expectedBuf);
|
|
366
|
-
if (!equal) {
|
|
373
|
+
// Constant-time comparison to defeat timing oracles (#157): matchBearerToken
|
|
374
|
+
// compares SHA-256 digests with timingSafeEqual against every configured token,
|
|
375
|
+
// static and legacy, with no early exit (#572).
|
|
376
|
+
const hit = matchBearerToken(match[1], bearerEntries);
|
|
377
|
+
if (!hit) {
|
|
367
378
|
logger.warn(`[HTTP] Unauthorized request from ${req.ip || req.connection.remoteAddress}: Invalid token`);
|
|
368
379
|
res.status(401).json({ error: 'Unauthorized: Invalid token' });
|
|
369
380
|
return false;
|
|
370
381
|
}
|
|
382
|
+
// A static principal carries its resolved allow-list into the requestContext.run
|
|
383
|
+
// calls below, where the adapter's ACL chokepoints read it. The legacy token sets
|
|
384
|
+
// neither field: principal 'static-bearer', unrestricted, exactly as before #572.
|
|
385
|
+
if (hit.allowedBudgets) {
|
|
386
|
+
req.staticPrincipal = hit.principal;
|
|
387
|
+
req.allowedBudgets = [...hit.allowedBudgets];
|
|
388
|
+
}
|
|
371
389
|
return true;
|
|
372
390
|
};
|
|
373
391
|
// Create a fresh Server instance per session
|
|
@@ -538,7 +556,15 @@ advertisedUrl) {
|
|
|
538
556
|
sessionInitPromises.set(sid, initPromise);
|
|
539
557
|
// Initialize connection pool for this session
|
|
540
558
|
try {
|
|
541
|
-
|
|
559
|
+
// #571: open on the session's own budget (a restored #189 preference may live on
|
|
560
|
+
// another server), not on the default one.
|
|
561
|
+
const budget = initialBudgetForSession(sid);
|
|
562
|
+
await connectToActualForSession(sid, {
|
|
563
|
+
serverUrl: budget.serverUrl,
|
|
564
|
+
password: budget.password || '',
|
|
565
|
+
syncId: budget.syncId,
|
|
566
|
+
encryptionPassword: budget.encryptionPassword,
|
|
567
|
+
});
|
|
542
568
|
// Only register the transport if the pool connection succeeded.
|
|
543
569
|
// The pool stamped lastActivity when it created the entry, so it
|
|
544
570
|
// is already the source of truth for this session's idle clock.
|
|
@@ -986,11 +1012,15 @@ advertisedUrl) {
|
|
|
986
1012
|
void runAclPreflight();
|
|
987
1013
|
}
|
|
988
1014
|
}
|
|
989
|
-
else if (
|
|
990
|
-
|
|
1015
|
+
else if (bearerEntries.length > 0) {
|
|
1016
|
+
// #572: principal names and budget COUNTS only; budget names are personal data.
|
|
1017
|
+
const principals = staticPrincipals.map((e) => `${e.principal} (${e.allowedBudgets.length} budget(s))`).join(', ');
|
|
1018
|
+
logger.info(`🔒 HTTP authentication enabled (Bearer token required)` +
|
|
1019
|
+
(principals ? `; static principals: ${principals}` : '') +
|
|
1020
|
+
(config.MCP_SSE_AUTHORIZATION ? `; MCP_SSE_AUTHORIZATION also accepted (unrestricted)` : ''));
|
|
991
1021
|
}
|
|
992
1022
|
else {
|
|
993
|
-
logger.warn(`⚠️ HTTP authentication disabled (no MCP_SSE_AUTHORIZATION or OIDC configured)`);
|
|
1023
|
+
logger.warn(`⚠️ HTTP authentication disabled (no MCP_SSE_AUTHORIZATION, MCP_STATIC_TOKENS or OIDC configured)`);
|
|
994
1024
|
}
|
|
995
1025
|
});
|
|
996
1026
|
// Configure keep-alive to maintain persistent connections
|
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.50",
|
|
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_acl_listing.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/static_tokens.test.js && node tests/unit/httpServer_static_tokens.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",
|