actual-mcp-server 0.11.3 → 0.11.5
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 +5 -2
- package/dist/src/auth/budget-acl-dynamic.js +222 -9
- package/dist/src/auth/budget-acl.js +8 -4
- package/dist/src/auth/identity-map.js +94 -0
- package/dist/src/auth/userinfo-identity.js +221 -0
- package/dist/src/config.js +50 -0
- package/dist/src/lib/actual-adapter.js +24 -5
- package/dist/src/lib/oidc-discovery.js +81 -0
- package/dist/src/server/httpServer.js +13 -1
- package/dist/src/server/stdioServer.js +46 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -41,7 +41,7 @@ Most Actual Budget MCP implementations are simple stdio bridges designed for sin
|
|
|
41
41
|
- **74 tools, the most comprehensive coverage available.** Accounts, transactions, categories, payees, tags, notes, rules, budgets, batch operations, bank sync, and more. Covers the reachable Actual Budget API with no genuine gaps.
|
|
42
42
|
- **HTTP and stdio transport.** Runs as a real remote server for LibreChat/LobeChat (`--http`), or as a direct local process for Claude Desktop (`--stdio`). No Docker or HTTP server is needed for local use.
|
|
43
43
|
- **6 exclusive ActualQL-powered tools.** Search and summarise transactions by month, amount, category, or payee using Actual Budget's native query engine. Aggregated results, no raw data dumped into the AI context window.
|
|
44
|
-
- **Multi-budget switching at runtime.** Configure multiple budget files and let the AI switch between them mid-conversation with `actual_budgets_switch`.
|
|
44
|
+
- **Multi-budget switching at runtime.** Configure multiple budget files and let the AI switch between them mid-conversation with `actual_budgets_switch`. Works on both transports: HTTP keys the active budget to the MCP session, and stdio (Claude Desktop, Claude Code, Cursor) gets a synthetic per-process session so a switch is scoped to that process rather than shared globally (#348).
|
|
45
45
|
- **Multi-user ready with OIDC.** Secure every session with JWKS-validated JWTs and per-user budget ACLs. No shared tokens required.
|
|
46
46
|
- **Production-grade reliability.** Connection pooling (up to 15 concurrent sessions), automatic retry with exponential backoff, and a full test suite (unit + E2E + integration).
|
|
47
47
|
|
|
@@ -506,6 +506,9 @@ All configuration is via environment variables. Copy `.env.example` to `.env` to
|
|
|
506
506
|
| `AUTH_BUDGET_ACL` | _(none)_ | No | Per-user budget ACL; see [AI Client Setup](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-multi-user) |
|
|
507
507
|
| `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 |
|
|
508
508
|
| `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 |
|
|
509
|
+
| `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 |
|
|
510
|
+
| `AUTH_BUDGET_ACL_IDENTITY_SOURCE` | `token` | No | Where the ACL reads identity claims from when the source is `actual`. `token` uses the verified access token (no network call). `userinfo` calls the IdP's UserInfo endpoint, which is the same document Actual derives `userName` from, and fixes deployments where the access token lacks the claim Actual stored. Opt-in because it makes the IdP a hard dependency of authorization |
|
|
511
|
+
| `AUTH_BUDGET_ACL_USERINFO_TIMEOUT_MS` | `5000` | No | Timeout for the UserInfo request, clamped to 250ms to 60s. No disable value: an unbounded call on the auth path turns a slow IdP into an outage |
|
|
509
512
|
| `MCP_ENABLE_HTTPS` | `false` | No | Enable native TLS. Requires `MCP_HTTPS_CERT` and `MCP_HTTPS_KEY` |
|
|
510
513
|
| `MCP_HTTPS_CERT` | _(none)_ | No | Path to PEM certificate file (required when `MCP_ENABLE_HTTPS=true`) |
|
|
511
514
|
| `MCP_HTTPS_KEY` | _(none)_ | No | Path to PEM private key file (required when `MCP_ENABLE_HTTPS=true`) |
|
|
@@ -771,4 +774,4 @@ The software is provided **as-is**, without warranty of any kind. The author acc
|
|
|
771
774
|
|
|
772
775
|
---
|
|
773
776
|
|
|
774
|
-
**Version:** 0.11.
|
|
777
|
+
**Version:** 0.11.5 | **Tool Count:** 74 (verified LibreChat-compatible)
|
|
@@ -51,6 +51,8 @@ import config from '../config.js';
|
|
|
51
51
|
import adapter from '../lib/actual-adapter.js';
|
|
52
52
|
import { requestContext } from '../lib/requestContext.js';
|
|
53
53
|
import { createModuleLogger } from '../lib/loggerFactory.js';
|
|
54
|
+
import { parseIdentityMap } from './identity-map.js';
|
|
55
|
+
import { resolveIdentityFromUserInfo, _resetUserInfoCache } from './userinfo-identity.js';
|
|
54
56
|
const log = createModuleLogger('ACL');
|
|
55
57
|
/**
|
|
56
58
|
* Cache of principal to allowed sync IDs.
|
|
@@ -65,6 +67,41 @@ const _cache = new Map();
|
|
|
65
67
|
/** Clear the resolver cache (test helper, and used when config is re-read). */
|
|
66
68
|
export function _resetDynamicAclCache() {
|
|
67
69
|
_cache.clear();
|
|
70
|
+
_identityMapCache = null;
|
|
71
|
+
// Chain the UserInfo identity cache. A caller resetting "the resolver caches"
|
|
72
|
+
// between cases would otherwise leave a 60s success entry keyed on the same
|
|
73
|
+
// `sub`, so the next case resolves from a stale identity and passes or fails for
|
|
74
|
+
// the wrong reason. That is the same "the fixture agrees with itself" hazard #344
|
|
75
|
+
// was written to remove, so it must not be reintroduced through the back door.
|
|
76
|
+
_resetUserInfoCache();
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The parsed identity map, memoised on the raw string it came from.
|
|
80
|
+
*
|
|
81
|
+
* Re-parsing per request would be wasteful, but caching on nothing would make the
|
|
82
|
+
* value untestable, since tests mutate config between cases. Keying the memo on
|
|
83
|
+
* the raw string gives both: one parse per distinct configuration.
|
|
84
|
+
*
|
|
85
|
+
* Parsing cannot throw here in practice, because config.ts validates the same
|
|
86
|
+
* string at startup. The catch is for the test path, which sets config directly
|
|
87
|
+
* and bypasses the schema, and it fails CLOSED: an unparseable map yields an
|
|
88
|
+
* empty map, which removes bindings rather than inventing them.
|
|
89
|
+
*/
|
|
90
|
+
let _identityMapCache = null;
|
|
91
|
+
export function getIdentityMap() {
|
|
92
|
+
const raw = config.AUTH_BUDGET_ACL_IDENTITY_MAP ?? '';
|
|
93
|
+
if (_identityMapCache && _identityMapCache.raw === raw)
|
|
94
|
+
return _identityMapCache.map;
|
|
95
|
+
let map;
|
|
96
|
+
try {
|
|
97
|
+
map = parseIdentityMap(raw);
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
log.error('dynamic ACL: AUTH_BUDGET_ACL_IDENTITY_MAP is unparseable; ignoring every binding', err);
|
|
101
|
+
map = new Map();
|
|
102
|
+
}
|
|
103
|
+
_identityMapCache = { raw, map };
|
|
104
|
+
return map;
|
|
68
105
|
}
|
|
69
106
|
/**
|
|
70
107
|
* Actual's OWN identity precedence, mirrored exactly (#343).
|
|
@@ -166,6 +203,178 @@ export function resolvedClaimName(claims, subject, claimName) {
|
|
|
166
203
|
}
|
|
167
204
|
return null;
|
|
168
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* Reported as `resolvedFromClaim` when an explicit binding decided the identity.
|
|
208
|
+
* Deliberately not a valid claim name, so a log line can never be ambiguous about
|
|
209
|
+
* whether a claim or the operator's map produced the principal.
|
|
210
|
+
*/
|
|
211
|
+
export const IDENTITY_MAP_SOURCE = 'identity-map';
|
|
212
|
+
/**
|
|
213
|
+
* Reported as the source when the identity came from the UserInfo endpoint (#346).
|
|
214
|
+
* Suffixed with the winning claim on success (`userinfo:preferred_username`), so a
|
|
215
|
+
* log line distinguishes a claim read from the token from the same claim read from
|
|
216
|
+
* UserInfo. When the join fails those are different diagnoses.
|
|
217
|
+
*/
|
|
218
|
+
export const USERINFO_SOURCE = 'userinfo';
|
|
219
|
+
/**
|
|
220
|
+
* Resolve the principal, consulting the explicit identity map first (#345).
|
|
221
|
+
*
|
|
222
|
+
* THE MAP IS AUTHORITATIVE, NOT A HINT. If this `sub` has a binding, that binding
|
|
223
|
+
* decides, and if it matches no file the principal is DENIED. It deliberately does
|
|
224
|
+
* NOT fall through to the claim precedence.
|
|
225
|
+
*
|
|
226
|
+
* That choice is the whole point of the feature. A fall-through would mean a typo
|
|
227
|
+
* in the map is masked by an accidental claim match: access is granted through a
|
|
228
|
+
* path the operator did not intend, and they never learn the binding is wrong
|
|
229
|
+
* because nothing ever fails. In an authorization control, a loud denial beats a
|
|
230
|
+
* silent divergence. The deny path names `identity-map` as the source precisely so
|
|
231
|
+
* the typo is diagnosable from the log line.
|
|
232
|
+
*
|
|
233
|
+
* `sub` is read the same way `extractPrincipalValue` reads it: from the verified
|
|
234
|
+
* token subject, falling back to a `sub` claim. A blank subject is treated as
|
|
235
|
+
* absent, so it can never be used as a map key.
|
|
236
|
+
*/
|
|
237
|
+
export function resolvePrincipal(claims, subject, claimName, identityMap = getIdentityMap()) {
|
|
238
|
+
if (identityMap.size > 0) {
|
|
239
|
+
const rawSub = typeof subject === 'string' ? subject : claims['sub'];
|
|
240
|
+
const sub = typeof rawSub === 'string' && rawSub.trim().length > 0 ? rawSub : null;
|
|
241
|
+
if (sub !== null) {
|
|
242
|
+
const bound = identityMap.get(sub);
|
|
243
|
+
// Config rejects a blank target, so `bound` is non-blank whenever present.
|
|
244
|
+
if (bound !== undefined)
|
|
245
|
+
return { value: bound, source: IDENTITY_MAP_SOURCE };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
value: extractPrincipalValue(claims, subject, claimName),
|
|
250
|
+
source: resolvedClaimName(claims, subject, claimName),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* #346: the async resolution used by the middleware.
|
|
255
|
+
*
|
|
256
|
+
* ORDER IS DELIBERATE, and the map coming first is not merely an optimisation:
|
|
257
|
+
*
|
|
258
|
+
* 1. AUTH_BUDGET_ACL_IDENTITY_MAP, if this sub is bound. An explicit operator
|
|
259
|
+
* binding must not be second-guessed by a network lookup, and it should not
|
|
260
|
+
* cost one either. This also keeps #345 usable as the escape hatch WHEN the
|
|
261
|
+
* UserInfo path is failing, which is precisely when you need an escape hatch.
|
|
262
|
+
* 2. The UserInfo endpoint, when AUTH_BUDGET_ACL_IDENTITY_SOURCE=userinfo.
|
|
263
|
+
* 3. The verified access-token claims (the default, unchanged).
|
|
264
|
+
*
|
|
265
|
+
* A UserInfo failure DENIES rather than falling back to step 3. Falling back would
|
|
266
|
+
* silently change which identity is resolved at the moment the system is least
|
|
267
|
+
* healthy, and would mask a permanent misconfiguration as an intermittent one.
|
|
268
|
+
*/
|
|
269
|
+
export async function resolvePrincipalAsync(claims, subject, accessToken) {
|
|
270
|
+
const claimName = config.AUTH_BUDGET_ACL_CLAIM;
|
|
271
|
+
const map = getIdentityMap();
|
|
272
|
+
// Step 1: an explicit binding wins outright, with no network call.
|
|
273
|
+
if (map.size > 0) {
|
|
274
|
+
const bound = resolvePrincipal(claims, subject, claimName, map);
|
|
275
|
+
if (bound.source === IDENTITY_MAP_SOURCE)
|
|
276
|
+
return bound;
|
|
277
|
+
}
|
|
278
|
+
// Step 2: the same document Actual read.
|
|
279
|
+
if (config.AUTH_BUDGET_ACL_IDENTITY_SOURCE === 'userinfo') {
|
|
280
|
+
const sub = typeof subject === 'string' && subject.trim().length > 0 ? subject : null;
|
|
281
|
+
if (sub === null || !accessToken) {
|
|
282
|
+
// Unreachable in production: httpServer rejects a token without a non-empty
|
|
283
|
+
// string `sub`, and the token is what got us here. Deny rather than quietly
|
|
284
|
+
// using the claims, so a future refactor that drops one of them is loud.
|
|
285
|
+
log.warn('dynamic ACL: userinfo source selected but no subject or access token is available; denying');
|
|
286
|
+
return { value: null, source: USERINFO_SOURCE };
|
|
287
|
+
}
|
|
288
|
+
// HONOUR A PINNED CLAIM HERE TOO. Passing ACTUAL_IDENTITY_PRECEDENCE
|
|
289
|
+
// unconditionally silently ignored AUTH_BUDGET_ACL_CLAIM on this path. An
|
|
290
|
+
// operator who pinned `email` precisely BECAUSE their IdP lets users edit
|
|
291
|
+
// `preferred_username` would have been reverted to `preferred_username` (first
|
|
292
|
+
// in the precedence) merely by switching identity source, reopening the
|
|
293
|
+
// impersonation vector the pin was chosen to close, while the deny log kept
|
|
294
|
+
// reporting `configuredClaim: email`. A security control that a neighbouring
|
|
295
|
+
// setting can silently disable is not a control.
|
|
296
|
+
const precedence = claimName === AUTO_CLAIM ? ACTUAL_IDENTITY_PRECEDENCE : [claimName];
|
|
297
|
+
const result = await resolveIdentityFromUserInfo(accessToken, sub, precedence);
|
|
298
|
+
return {
|
|
299
|
+
value: result.value,
|
|
300
|
+
// Report the winning claim, tagged so a log reader can tell WHERE it was
|
|
301
|
+
// read from. `preferred_username` from the token and from UserInfo are very
|
|
302
|
+
// different diagnoses when the join fails.
|
|
303
|
+
source: result.value !== null ? `${USERINFO_SOURCE}:${result.claim}` : USERINFO_SOURCE,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
// Step 3: unchanged.
|
|
307
|
+
return resolvePrincipal(claims, subject, claimName, map);
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* The distinct non-blank `userName`s a getBudgets() payload offers.
|
|
311
|
+
*
|
|
312
|
+
* Shared by the deny path and the startup preflight so the two can never disagree
|
|
313
|
+
* about what was on offer, which would make the diagnostic actively misleading.
|
|
314
|
+
*/
|
|
315
|
+
export function collectKnownUserNames(files) {
|
|
316
|
+
if (!Array.isArray(files))
|
|
317
|
+
return [];
|
|
318
|
+
return [
|
|
319
|
+
...new Set(files
|
|
320
|
+
.flatMap((f) => (Array.isArray(f?.usersWithAccess) ? f.usersWithAccess : []))
|
|
321
|
+
.map((u) => u?.userName)
|
|
322
|
+
.filter((n) => typeof n === 'string' && n.trim().length > 0)),
|
|
323
|
+
];
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* #345: one-shot startup diagnostic for AUTH_BUDGET_ACL_SOURCE=actual.
|
|
327
|
+
*
|
|
328
|
+
* Before this, the first sign that the join was misconfigured was a 403 during
|
|
329
|
+
* use, with the operator holding no list of what they were supposed to match.
|
|
330
|
+
* #343 shipped a TOTAL denial that survived two releases for exactly that reason.
|
|
331
|
+
* One line at boot naming the `userName`s on offer turns that into a five second
|
|
332
|
+
* fix.
|
|
333
|
+
*
|
|
334
|
+
* NEVER THROWS, AND NEVER BLOCKS STARTUP. The Actual server is a separate process
|
|
335
|
+
* that may legitimately come up after this one, so a hard dependency here would
|
|
336
|
+
* turn a transient upstream outage into a boot loop. A failure is a warn and the
|
|
337
|
+
* server starts anyway.
|
|
338
|
+
*
|
|
339
|
+
* It also warms the resolver cache for the first real request, since it performs
|
|
340
|
+
* the same read.
|
|
341
|
+
*/
|
|
342
|
+
export async function runAclPreflight() {
|
|
343
|
+
let files;
|
|
344
|
+
try {
|
|
345
|
+
files = await requestContext.run({ allowedBudgets: ['*'] }, () => adapter.getBudgets());
|
|
346
|
+
}
|
|
347
|
+
catch (err) {
|
|
348
|
+
log.warn('dynamic ACL preflight: could not read the budget list; continuing startup', {
|
|
349
|
+
error: err instanceof Error ? err.message : String(err),
|
|
350
|
+
hint: 'The ACL will retry on the first request. If this persists, users will get 403s.',
|
|
351
|
+
});
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const known = collectKnownUserNames(files);
|
|
355
|
+
const fileCount = Array.isArray(files) ? files.length : 0;
|
|
356
|
+
const mapEntries = getIdentityMap().size;
|
|
357
|
+
if (known.length === 0) {
|
|
358
|
+
// The password-mode case. Actual only populates userName for OpenID users, so
|
|
359
|
+
// on a password-mode server every row is blank and NOBODY can ever resolve.
|
|
360
|
+
// Saying so here is the difference between a five second fix and the multi
|
|
361
|
+
// release investigation that #343 became.
|
|
362
|
+
log.warn('dynamic ACL preflight: no non-blank userName on any budget file; no principal can resolve', {
|
|
363
|
+
fileCount,
|
|
364
|
+
identityMapEntries: mapEntries,
|
|
365
|
+
hint: 'Actual only populates userName for OpenID users. If that server runs in password mode, ' +
|
|
366
|
+
'AUTH_BUDGET_ACL_SOURCE=actual cannot resolve anyone; use AUTH_BUDGET_ACL_SOURCE=static.',
|
|
367
|
+
});
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
log.info('dynamic ACL preflight: budget access list read', {
|
|
371
|
+
fileCount,
|
|
372
|
+
knownUserNames: known,
|
|
373
|
+
configuredClaim: config.AUTH_BUDGET_ACL_CLAIM,
|
|
374
|
+
identityMapEntries: mapEntries,
|
|
375
|
+
hint: 'A principal must resolve to one of the userNames above, or it is denied.',
|
|
376
|
+
});
|
|
377
|
+
}
|
|
169
378
|
/**
|
|
170
379
|
* Map a getBudgets() payload to the sync IDs the given principal may access.
|
|
171
380
|
*
|
|
@@ -250,20 +459,24 @@ export async function resolveAllowedBudgetsFromActual(principalValue, lastResolv
|
|
|
250
459
|
// offer, so the next mismatch is diagnosable from one log line instead of a
|
|
251
460
|
// debugging session. The principal itself is not logged at warn level (it is
|
|
252
461
|
// an identity); it is available at debug.
|
|
253
|
-
const offered =
|
|
254
|
-
|
|
255
|
-
.flatMap((f) => (Array.isArray(f?.usersWithAccess) ? f.usersWithAccess : []))
|
|
256
|
-
.map((u) => u?.userName)
|
|
257
|
-
.filter((n) => typeof n === 'string' && n.trim().length > 0))]
|
|
258
|
-
: [];
|
|
462
|
+
const offered = collectKnownUserNames(files);
|
|
463
|
+
const fromMap = lastResolvedClaim === IDENTITY_MAP_SOURCE;
|
|
259
464
|
log.warn('dynamic ACL: principal matched no budget file', {
|
|
260
465
|
configuredClaim: config.AUTH_BUDGET_ACL_CLAIM,
|
|
261
466
|
resolvedFromClaim: lastResolvedClaim ?? '(none)',
|
|
262
467
|
fileCount: Array.isArray(files) ? files.length : 0,
|
|
263
468
|
knownUserNames: offered,
|
|
264
|
-
hint:
|
|
265
|
-
|
|
266
|
-
|
|
469
|
+
hint: fromMap
|
|
470
|
+
? // #345: a bound principal that matches nothing is an operator typo, not an
|
|
471
|
+
// IdP claim problem, so pointing at claim mappers here would send them to
|
|
472
|
+
// the wrong place entirely.
|
|
473
|
+
'AUTH_BUDGET_ACL_IDENTITY_MAP bound this sub to a userName that no budget file offers. ' +
|
|
474
|
+
'Correct the binding to one of the userNames above. The map is authoritative, so no claim ' +
|
|
475
|
+
'fallback was attempted.'
|
|
476
|
+
: 'The value from the token did not equal any userName above. Actual derives userName from the ' +
|
|
477
|
+
'UserInfo response; this server reads the ACCESS TOKEN. If they differ, add a claim mapper at your ' +
|
|
478
|
+
'IdP, pin AUTH_BUDGET_ACL_CLAIM to a claim present in the access token, or bind this sub explicitly ' +
|
|
479
|
+
'with AUTH_BUDGET_ACL_IDENTITY_MAP.',
|
|
267
480
|
});
|
|
268
481
|
log.debug('dynamic ACL: unmatched principal', { principal: principalValue });
|
|
269
482
|
}
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
// to filter results by permitted sync IDs (forward-looking multi-budget support).
|
|
25
25
|
import config from '../config.js';
|
|
26
26
|
import logger from '../logger.js';
|
|
27
|
-
import {
|
|
27
|
+
import { resolveAllowedBudgetsFromActual, resolvePrincipalAsync } from './budget-acl-dynamic.js';
|
|
28
28
|
// ---------------------------------------------------------------------------
|
|
29
29
|
// ACL map (parsed once from env, cached)
|
|
30
30
|
// ---------------------------------------------------------------------------
|
|
@@ -156,9 +156,13 @@ async function resolveDynamicAcl(req, res, next) {
|
|
|
156
156
|
const claims = (auth?.claims ?? {});
|
|
157
157
|
const subject = auth?.subject;
|
|
158
158
|
try {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
159
|
+
// #345/#346: the map is consulted first and is authoritative when this sub is
|
|
160
|
+
// bound; otherwise the identity comes from the UserInfo endpoint or the access
|
|
161
|
+
// token, per AUTH_BUDGET_ACL_IDENTITY_SOURCE. An unmapped deployment on the
|
|
162
|
+
// default source behaves exactly as it did before both tickets.
|
|
163
|
+
const token = auth?.token;
|
|
164
|
+
const { value: principalValue, source } = await resolvePrincipalAsync(claims, subject, token);
|
|
165
|
+
const allowed = await resolveAllowedBudgetsFromActual(principalValue, source);
|
|
162
166
|
if (allowed.length > 0) {
|
|
163
167
|
req.allowedBudgets = allowed;
|
|
164
168
|
next();
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// src/auth/identity-map.ts
|
|
2
|
+
//
|
|
3
|
+
// #345: explicit `sub` to Actual `userName` bindings for the dynamic budget ACL.
|
|
4
|
+
//
|
|
5
|
+
// WHY THIS EXISTS. The dynamic ACL joins a token claim to Actual's `user_name`,
|
|
6
|
+
// and two properties of Actual make that join incomplete no matter how good the
|
|
7
|
+
// claim heuristic is:
|
|
8
|
+
//
|
|
9
|
+
// 1. Actual derives `user_name` from the USERINFO RESPONSE
|
|
10
|
+
// (`packages/sync-server/src/accounts/openid.ts`:
|
|
11
|
+
// `const userInfo = await client.userinfo(tokenSet.access_token)`), while this
|
|
12
|
+
// server reads the verified ACCESS TOKEN payload. An IdP that returns
|
|
13
|
+
// `preferred_username` from UserInfo but omits it from the access token
|
|
14
|
+
// produces a total, silent denial. (#346 addresses that axis directly.)
|
|
15
|
+
// 2. `user_name` is FROZEN at first login. The INSERT sets it once; the
|
|
16
|
+
// returning-user branch updates only `display_name`. So an IdP-side rename
|
|
17
|
+
// breaks the join permanently, and no claim-based scheme can repair it.
|
|
18
|
+
//
|
|
19
|
+
// An explicit operator-authored binding answers both, because it depends on
|
|
20
|
+
// neither the claim set nor the current value of any mutable IdP attribute.
|
|
21
|
+
//
|
|
22
|
+
// WHY IT IS KEYED ON `sub`. `sub` is the one claim we already require to be
|
|
23
|
+
// present and verified (httpServer.ts rejects a token whose `sub` is not a
|
|
24
|
+
// non-empty string), OIDC Core guarantees it is locally unique and never
|
|
25
|
+
// reassigned, and it is unaffected by a rename at either end. Keying on the
|
|
26
|
+
// CONFIGURED claim instead would be circular: the whole reason this exists is
|
|
27
|
+
// that the configured claim may be absent from the access token.
|
|
28
|
+
//
|
|
29
|
+
// WHY THIS MODULE HAS NO IMPORTS. `config.ts` validates the raw env value at
|
|
30
|
+
// startup by calling parseIdentityMap, and `budget-acl-dynamic.ts` consumes the
|
|
31
|
+
// parsed result. If the parser lived in the latter, config would import a module
|
|
32
|
+
// that imports config, and the cycle would leave one of them half-initialised at
|
|
33
|
+
// module-evaluation time. Keeping it dependency-free is what makes validation at
|
|
34
|
+
// config-parse time possible at all.
|
|
35
|
+
/**
|
|
36
|
+
* Parse `AUTH_BUDGET_ACL_IDENTITY_MAP`.
|
|
37
|
+
*
|
|
38
|
+
* Format: `<sub>=<actual userName>[,<sub>=<userName>...]`
|
|
39
|
+
*
|
|
40
|
+
* Throws on any malformed entry rather than skipping it. A silently dropped
|
|
41
|
+
* binding is an authorization change the operator did not ask for and would have
|
|
42
|
+
* no way to notice: they would see a 403 and no reason for it. Startup is the
|
|
43
|
+
* cheapest possible moment to surface it.
|
|
44
|
+
*
|
|
45
|
+
* TRIMMING, and why it differs from the token side. Both halves are trimmed
|
|
46
|
+
* here. On the token side `extractPrincipalValue` deliberately does NOT trim the
|
|
47
|
+
* value it compares, because a user who can register `" alice"` at the IdP would
|
|
48
|
+
* otherwise match the real `alice`. That reasoning does not apply to this file:
|
|
49
|
+
* the values are operator-authored, not attacker-supplied, and `KEY = value`
|
|
50
|
+
* spacing in an env file is ordinary. The cost is that an Actual `userName` with
|
|
51
|
+
* genuine leading or trailing whitespace cannot be expressed here; it would fail
|
|
52
|
+
* to match and the principal would be denied, which is the correct direction.
|
|
53
|
+
*/
|
|
54
|
+
export function parseIdentityMap(raw) {
|
|
55
|
+
const map = new Map();
|
|
56
|
+
if (raw === undefined || raw === null)
|
|
57
|
+
return map;
|
|
58
|
+
const trimmed = raw.trim();
|
|
59
|
+
if (trimmed.length === 0)
|
|
60
|
+
return map;
|
|
61
|
+
for (const rawEntry of trimmed.split(',')) {
|
|
62
|
+
const entry = rawEntry.trim();
|
|
63
|
+
if (entry.length === 0)
|
|
64
|
+
continue; // tolerate a trailing comma
|
|
65
|
+
const eq = entry.indexOf('=');
|
|
66
|
+
if (eq === -1) {
|
|
67
|
+
throw new Error(`entry "${entry}" is not "<sub>=<userName>" (no "=" found)`);
|
|
68
|
+
}
|
|
69
|
+
if (entry.indexOf('=', eq + 1) !== -1) {
|
|
70
|
+
throw new Error(`entry "${entry}" contains more than one "="; a sub or userName containing "=" cannot be expressed here`);
|
|
71
|
+
}
|
|
72
|
+
const sub = entry.slice(0, eq).trim();
|
|
73
|
+
const userName = entry.slice(eq + 1).trim();
|
|
74
|
+
if (sub.length === 0) {
|
|
75
|
+
throw new Error(`entry "${entry}" has a blank sub on the left of "="`);
|
|
76
|
+
}
|
|
77
|
+
// A BLANK TARGET IS THE DANGEROUS ONE, and it is rejected here rather than
|
|
78
|
+
// left to the matcher. This server's own service account appears in EVERY
|
|
79
|
+
// file's usersWithAccess with `owner: true` and a blank `userName`, so a
|
|
80
|
+
// binding to '' would resolve its principal to owner access on every budget
|
|
81
|
+
// in the deployment. matchAllowedFiles already skips blank candidates; this
|
|
82
|
+
// is the second, explicit guard, and it fails at startup instead of granting
|
|
83
|
+
// nothing quietly at request time.
|
|
84
|
+
if (userName.length === 0) {
|
|
85
|
+
throw new Error(`entry "${entry}" has a blank userName; a blank target would match the service-account row that owns every file`);
|
|
86
|
+
}
|
|
87
|
+
if (map.has(sub)) {
|
|
88
|
+
// Last-wins would be a silent authorization change decided by ordering.
|
|
89
|
+
throw new Error(`duplicate sub "${sub}"; each sub may appear at most once`);
|
|
90
|
+
}
|
|
91
|
+
map.set(sub, userName);
|
|
92
|
+
}
|
|
93
|
+
return map;
|
|
94
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// src/auth/userinfo-identity.ts
|
|
2
|
+
//
|
|
3
|
+
// #346: resolve the ACL principal from the IdP's UserInfo endpoint, which is the
|
|
4
|
+
// SAME document Actual derives `user_name` from.
|
|
5
|
+
//
|
|
6
|
+
// THE MISMATCH THIS REMOVES. Actual's login path is:
|
|
7
|
+
//
|
|
8
|
+
// const userInfo = await client.userinfo(tokenSet.access_token); // openid.ts
|
|
9
|
+
// const identity = userInfo.preferred_username ?? userInfo.login ??
|
|
10
|
+
// userInfo.email ?? userInfo.id ?? userInfo.sub;
|
|
11
|
+
// INSERT INTO users (id, user_name, ...) VALUES (uuidv4(), identity, ...)
|
|
12
|
+
//
|
|
13
|
+
// This server reads the verified ACCESS TOKEN payload instead. Those are two
|
|
14
|
+
// different documents, and several IdPs (Authentik and Keycloak among them) return
|
|
15
|
+
// `preferred_username` from UserInfo while omitting it from the access token unless
|
|
16
|
+
// a property mapping is configured. When that happens every principal resolves to a
|
|
17
|
+
// value Actual never stored, and the result is a total, silent denial. Reading the
|
|
18
|
+
// same document Actual read removes the whole class rather than one instance of it.
|
|
19
|
+
//
|
|
20
|
+
// WHY IT IS OPT-IN AND OFF BY DEFAULT. It trades a correctness bug for an
|
|
21
|
+
// availability dependency: the IdP becomes a hard dependency of authorization, and
|
|
22
|
+
// it needs the access token to carry the `openid` scope, which this server does not
|
|
23
|
+
// require (OIDC_SCOPES is optional). #345's offline identity map is the right
|
|
24
|
+
// default answer; this is for deployments that want the join to be automatic.
|
|
25
|
+
import config from '../config.js';
|
|
26
|
+
import { createModuleLogger } from '../lib/loggerFactory.js';
|
|
27
|
+
import { discoverOidcMetadata, resolveUserInfoUri, getResolvedOidcMetadata, buildTrustedJwksHosts, } from '../lib/oidc-discovery.js';
|
|
28
|
+
const log = createModuleLogger('ACL');
|
|
29
|
+
/**
|
|
30
|
+
* Identity cache, keyed on the verified `sub`.
|
|
31
|
+
*
|
|
32
|
+
* Keyed on `sub` rather than on the resolved identity because the identity is not
|
|
33
|
+
* known until AFTER the request that this cache exists to avoid. `sub` is always
|
|
34
|
+
* present and verified, so it is available before the call.
|
|
35
|
+
*
|
|
36
|
+
* SUCCESSES ONLY. Caching a failure would keep a transient IdP blip in force for
|
|
37
|
+
* the whole TTL; the timeout below is what bounds the cost of an unhealthy endpoint,
|
|
38
|
+
* not a negative cache. The TTL matches the ACL's own budget cache, so enabling this
|
|
39
|
+
* does not widen the revocation window that already exists.
|
|
40
|
+
*/
|
|
41
|
+
const CACHE_TTL_MS = 60_000;
|
|
42
|
+
/** Hard cap on retained identities; see the eviction note at the write site. */
|
|
43
|
+
const MAX_CACHE_ENTRIES = 1000;
|
|
44
|
+
const _cache = new Map();
|
|
45
|
+
export function _resetUserInfoCache() {
|
|
46
|
+
_cache.clear();
|
|
47
|
+
}
|
|
48
|
+
/** Test seam: how many outbound UserInfo requests have been made. */
|
|
49
|
+
let _fetchCount = 0;
|
|
50
|
+
export function _userInfoFetchCount() {
|
|
51
|
+
return _fetchCount;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Read a precedence claim out of an untrusted UserInfo body.
|
|
55
|
+
*
|
|
56
|
+
* NON-STRINGS ARE ABSENT, NOT COERCED. A hostile or broken IdP returning
|
|
57
|
+
* `preferred_username: {}` must not become the string "[object Object]", and
|
|
58
|
+
* `email: 42` must not become "42". Coercion here would invent an identity that
|
|
59
|
+
* the operator never configured and Actual never stored. Blank is absent too, for
|
|
60
|
+
* the same reason it is in extractPrincipalValue: the service-account row that owns
|
|
61
|
+
* every budget file has a blank `userName`.
|
|
62
|
+
*/
|
|
63
|
+
function readClaim(body, name) {
|
|
64
|
+
const raw = body[name];
|
|
65
|
+
if (typeof raw !== 'string')
|
|
66
|
+
return null;
|
|
67
|
+
return raw.trim().length > 0 ? raw : null;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Resolve the identity for `subject` by calling the IdP's UserInfo endpoint with
|
|
71
|
+
* the caller's own access token.
|
|
72
|
+
*
|
|
73
|
+
* FAILS CLOSED on every path. `precedence` is passed in rather than imported so
|
|
74
|
+
* this module cannot drift from the single definition of Actual's ordering.
|
|
75
|
+
*/
|
|
76
|
+
export async function resolveIdentityFromUserInfo(accessToken, subject, precedence, fetchImpl = fetch) {
|
|
77
|
+
const cached = _cache.get(subject);
|
|
78
|
+
if (cached && Date.now() - cached.at < CACHE_TTL_MS) {
|
|
79
|
+
return { value: cached.value, claim: cached.claim, failure: null };
|
|
80
|
+
}
|
|
81
|
+
let endpoint;
|
|
82
|
+
try {
|
|
83
|
+
// PREFER THE DOCUMENT RESOLVED AT STARTUP. Re-fetching discovery here was wrong
|
|
84
|
+
// twice over. It put an outbound fetch on the authorization path, breaking the
|
|
85
|
+
// invariant oidc-discovery.ts states explicitly ("no request ever triggers an
|
|
86
|
+
// outbound fetch, no request-path SSRF surface"), and it did so with an EMPTY
|
|
87
|
+
// trusted-hosts list while the startup call passes
|
|
88
|
+
// buildTrustedJwksHosts(OIDC_JWKS_TRUSTED_HOSTS). A deployment with a
|
|
89
|
+
// legitimately cross-origin jwks_uri (Google: issuer accounts.google.com, keys
|
|
90
|
+
// on www.googleapis.com, the case docs/CONFIGURATION.md documents) would then
|
|
91
|
+
// throw inside resolveJwksUri, be caught below, and deny EVERY request while
|
|
92
|
+
// the log blamed the UserInfo endpoint. That is #343's shape exactly: a total
|
|
93
|
+
// silent denial with a diagnostic pointing at the wrong subsystem.
|
|
94
|
+
//
|
|
95
|
+
// The fallback fetch exists for callers with no HTTP bootstrap, and it now
|
|
96
|
+
// passes the same trusted hosts, so neither path can drift from the other.
|
|
97
|
+
let metadata = getResolvedOidcMetadata();
|
|
98
|
+
if (metadata === null) {
|
|
99
|
+
({ metadata } = await discoverOidcMetadata(config.OIDC_ISSUER, config.OIDC_ALLOW_INSECURE_ISSUER === true, buildTrustedJwksHosts(config.OIDC_JWKS_TRUSTED_HOSTS)));
|
|
100
|
+
}
|
|
101
|
+
endpoint = resolveUserInfoUri(metadata, config.OIDC_ISSUER, config.OIDC_ALLOW_INSECURE_ISSUER === true);
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
log.error('dynamic ACL: could not resolve the UserInfo endpoint; denying', err);
|
|
105
|
+
return { value: null, claim: null, failure: 'unavailable' };
|
|
106
|
+
}
|
|
107
|
+
const controller = new AbortController();
|
|
108
|
+
const timer = setTimeout(() => controller.abort(), config.AUTH_BUDGET_ACL_USERINFO_TIMEOUT_MS);
|
|
109
|
+
let res;
|
|
110
|
+
try {
|
|
111
|
+
_fetchCount += 1;
|
|
112
|
+
res = await fetchImpl(endpoint, {
|
|
113
|
+
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
|
|
114
|
+
signal: controller.signal,
|
|
115
|
+
redirect: 'error',
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
// Timeout or transport failure. Deliberately a DIFFERENT disposition from a
|
|
120
|
+
// 401: this one says the IdP is unreachable, which is an availability problem
|
|
121
|
+
// rather than a configuration one, and the two have different fixes.
|
|
122
|
+
clearTimeout(timer);
|
|
123
|
+
log.warn('dynamic ACL: UserInfo request failed; denying', {
|
|
124
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
125
|
+
timeoutMs: config.AUTH_BUDGET_ACL_USERINFO_TIMEOUT_MS,
|
|
126
|
+
hint: 'The IdP was unreachable or too slow. This is an availability failure, not a claim mismatch.',
|
|
127
|
+
});
|
|
128
|
+
return { value: null, claim: null, failure: 'unavailable' };
|
|
129
|
+
}
|
|
130
|
+
// NOTE: the timer is NOT cleared here. Clearing it as soon as the headers arrive
|
|
131
|
+
// disarms the AbortController before the body is read, so a slow-loris IdP (or a
|
|
132
|
+
// proxy that returns 200 headers then trickles the body) would hang the
|
|
133
|
+
// authorization path indefinitely: exactly the failure this timeout exists to
|
|
134
|
+
// prevent. It stays armed until the body is fully consumed below.
|
|
135
|
+
if (res.status === 401 || res.status === 403) {
|
|
136
|
+
clearTimeout(timer);
|
|
137
|
+
log.warn('dynamic ACL: the IdP refused the access token at UserInfo; denying', {
|
|
138
|
+
status: res.status,
|
|
139
|
+
hint: 'UserInfo requires a token issued with the "openid" scope. This server does not require that scope ' +
|
|
140
|
+
'(OIDC_SCOPES is optional), so the client may not have requested it. Add "openid" to the client scopes, ' +
|
|
141
|
+
'or use AUTH_BUDGET_ACL_IDENTITY_SOURCE=token with AUTH_BUDGET_ACL_IDENTITY_MAP instead.',
|
|
142
|
+
});
|
|
143
|
+
return { value: null, claim: null, failure: 'token-refused' };
|
|
144
|
+
}
|
|
145
|
+
if (!res.ok) {
|
|
146
|
+
clearTimeout(timer);
|
|
147
|
+
log.warn('dynamic ACL: UserInfo returned an unexpected status; denying', { status: res.status });
|
|
148
|
+
return { value: null, claim: null, failure: 'unavailable' };
|
|
149
|
+
}
|
|
150
|
+
let body;
|
|
151
|
+
try {
|
|
152
|
+
const parsed = await res.json();
|
|
153
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
154
|
+
throw new Error('UserInfo body is not a JSON object');
|
|
155
|
+
}
|
|
156
|
+
body = parsed;
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
// Covers an HTML error page, a truncated body, and a signed (application/jwt)
|
|
160
|
+
// response. A JWT response is NOT parsed here: verifying it needs the JWKS and
|
|
161
|
+
// the same audience rules as the access token, and accepting it unverified
|
|
162
|
+
// would let anything that can answer this URL choose the caller's identity.
|
|
163
|
+
log.warn('dynamic ACL: UserInfo body was not usable JSON; denying', {
|
|
164
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
165
|
+
contentType: res.headers?.get?.('content-type') ?? '(unknown)',
|
|
166
|
+
hint: 'A signed (application/jwt) UserInfo response is not supported yet and is refused rather than ' +
|
|
167
|
+
'trusted unverified. Configure the IdP to return application/json.',
|
|
168
|
+
});
|
|
169
|
+
return { value: null, claim: null, failure: 'unavailable' };
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
// The body is now either parsed or failed; either way the request is over.
|
|
173
|
+
clearTimeout(timer);
|
|
174
|
+
}
|
|
175
|
+
// OIDC Core 5.3.2: the client MUST verify that the `sub` in the UserInfo response
|
|
176
|
+
// matches the `sub` it already holds. This is not defensive polish; without it,
|
|
177
|
+
// anything able to answer as the userinfo_endpoint (a compromised IdP, a
|
|
178
|
+
// misconfigured proxy, a hijacked DNS entry) could return ANOTHER user's
|
|
179
|
+
// preferred_username and hand that user's budgets to this caller. A mismatch is a
|
|
180
|
+
// hard deny, and it must never fall back to the token claims: falling back would
|
|
181
|
+
// turn a detected attack into a silent downgrade.
|
|
182
|
+
const responseSub = body['sub'];
|
|
183
|
+
if (typeof responseSub !== 'string' || responseSub !== subject) {
|
|
184
|
+
log.error('dynamic ACL: UserInfo subject mismatch; denying', undefined, {
|
|
185
|
+
hint: 'The UserInfo response described a different principal than the access token (OIDC Core 5.3.2). ' +
|
|
186
|
+
'This is refused outright and is never retried against the token claims. If it persists, treat the ' +
|
|
187
|
+
'UserInfo endpoint as untrusted.',
|
|
188
|
+
responseSubType: typeof responseSub,
|
|
189
|
+
});
|
|
190
|
+
return { value: null, claim: null, failure: 'subject-mismatch' };
|
|
191
|
+
}
|
|
192
|
+
for (const name of precedence) {
|
|
193
|
+
const value = readClaim(body, name);
|
|
194
|
+
if (value !== null) {
|
|
195
|
+
// Bound the cache. The key space is one entry per distinct `sub` seen, which
|
|
196
|
+
// an issuer-trusted caller can grow by minting tokens for many subjects, so
|
|
197
|
+
// unlike the ACL's operator-sized principal cache this dimension is not
|
|
198
|
+
// self-limiting. Sweep expired entries first, then cap: cheap, and it keeps
|
|
199
|
+
// a long-lived process from retaining every identity it has ever resolved.
|
|
200
|
+
if (_cache.size >= MAX_CACHE_ENTRIES) {
|
|
201
|
+
const cutoff = Date.now() - CACHE_TTL_MS;
|
|
202
|
+
for (const [k, v] of _cache)
|
|
203
|
+
if (v.at < cutoff)
|
|
204
|
+
_cache.delete(k);
|
|
205
|
+
while (_cache.size >= MAX_CACHE_ENTRIES) {
|
|
206
|
+
const oldest = _cache.keys().next();
|
|
207
|
+
if (oldest.done)
|
|
208
|
+
break;
|
|
209
|
+
_cache.delete(oldest.value);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
_cache.set(subject, { at: Date.now(), value, claim: name });
|
|
213
|
+
return { value, claim: name, failure: null };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
log.warn('dynamic ACL: UserInfo carried no usable identity claim; denying', {
|
|
217
|
+
precedence: [...precedence],
|
|
218
|
+
hint: 'The response was valid but contained none of the claims Actual derives user_name from.',
|
|
219
|
+
});
|
|
220
|
+
return { value: null, claim: null, failure: 'no-identity' };
|
|
221
|
+
}
|
package/dist/src/config.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { parseIdentityMap } from './auth/identity-map.js';
|
|
2
3
|
export const configSchema = z.object({
|
|
3
4
|
ACTUAL_SERVER_URL: z.string().url(),
|
|
4
5
|
ACTUAL_PASSWORD: z.string().default(''),
|
|
@@ -123,6 +124,55 @@ export const configSchema = z.object({
|
|
|
123
124
|
.string()
|
|
124
125
|
.default('auto')
|
|
125
126
|
.refine((v) => v === 'auto' || /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/.test(v), 'AUTH_BUDGET_ACL_CLAIM must be "auto" or a simple claim name'),
|
|
127
|
+
// #346: WHERE the identity claims are read from when AUTH_BUDGET_ACL_SOURCE=actual.
|
|
128
|
+
//
|
|
129
|
+
// token (default) the verified access-token payload. No network call.
|
|
130
|
+
// userinfo the IdP's UserInfo endpoint, which is the SAME document Actual
|
|
131
|
+
// derives user_name from (openid.ts calls client.userinfo()).
|
|
132
|
+
//
|
|
133
|
+
// Default stays `token` deliberately. `userinfo` is more likely to match, but it
|
|
134
|
+
// makes the IdP a hard dependency of authorization: its downtime becomes a total
|
|
135
|
+
// denial, and it requires the access token to carry the `openid` scope, which
|
|
136
|
+
// this server does not require. Correctness that costs availability should be an
|
|
137
|
+
// operator's explicit choice, not a silent upgrade.
|
|
138
|
+
AUTH_BUDGET_ACL_IDENTITY_SOURCE: z.enum(['token', 'userinfo']).default('token'),
|
|
139
|
+
// #346: bound on the UserInfo request, so a hanging IdP cannot stall the
|
|
140
|
+
// authorization path. Same parse-and-clamp shape as ACTUAL_OP_TIMEOUT_MS, but it
|
|
141
|
+
// has NO disable value: a request on the auth path with no timeout is how a slow
|
|
142
|
+
// IdP becomes an outage. A non-numeric or out-of-range value falls back to 5000.
|
|
143
|
+
AUTH_BUDGET_ACL_USERINFO_TIMEOUT_MS: z.string().default('5000').transform((val) => {
|
|
144
|
+
const n = parseInt(val, 10);
|
|
145
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
146
|
+
return 5000;
|
|
147
|
+
return Math.min(Math.max(n, 250), 60000);
|
|
148
|
+
}),
|
|
149
|
+
// #345: explicit `<sub>=<actual userName>` bindings, consulted BEFORE the claim
|
|
150
|
+
// precedence and authoritative when the sub is present. See src/auth/identity-map.ts
|
|
151
|
+
// for why it is keyed on `sub` and why a blank target is rejected.
|
|
152
|
+
//
|
|
153
|
+
// Validated here (not at first request) so a malformed binding is a startup
|
|
154
|
+
// error naming the offending entry. The parsed Map is rebuilt on use rather
|
|
155
|
+
// than stored on the config object, because this schema is also consumed by
|
|
156
|
+
// drift tooling that expects plain values.
|
|
157
|
+
AUTH_BUDGET_ACL_IDENTITY_MAP: z
|
|
158
|
+
.string()
|
|
159
|
+
.default('')
|
|
160
|
+
.superRefine((v, ctx) => {
|
|
161
|
+
try {
|
|
162
|
+
parseIdentityMap(v);
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
// Surface the parser's own message. It names the offending entry, which
|
|
166
|
+
// is the only part the operator can act on.
|
|
167
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
168
|
+
ctx.addIssue({
|
|
169
|
+
code: 'custom',
|
|
170
|
+
message: `AUTH_BUDGET_ACL_IDENTITY_MAP is malformed: ${reason}. ` +
|
|
171
|
+
'Expected "<sub>=<actual userName>" entries separated by commas, for example ' +
|
|
172
|
+
'"a1b2c3=jdoe,d4e5f6=asmith". See docs/CONFIGURATION.md.',
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}),
|
|
126
176
|
})
|
|
127
177
|
// #343 UPGRADE GUARD. v0.11.0 and v0.11.1 shipped AUTH_BUDGET_ACL_CLAIM=sub as
|
|
128
178
|
// the default AND the README told operators to keep it. That combination cannot
|
|
@@ -1771,8 +1771,10 @@ export async function getBudgets() {
|
|
|
1771
1771
|
*
|
|
1772
1772
|
* Issue #156:
|
|
1773
1773
|
* * Per-session: writes to the per-session map keyed by current sessionId.
|
|
1774
|
-
*
|
|
1775
|
-
*
|
|
1774
|
+
* Since #348 stdio HAS a session (a synthetic per-process id minted in
|
|
1775
|
+
* stdioServer.ts), so it can switch. Genuinely session-less callers (CLI
|
|
1776
|
+
* scripts, startup health checks) still fall back to the env-default budget
|
|
1777
|
+
* and cannot switch (returns an error).
|
|
1776
1778
|
* * ACL: refuses when the target budget's syncId is not in this session's
|
|
1777
1779
|
* allowedBudgets.
|
|
1778
1780
|
* * Exact match only (substring matching removed: it was a sharp edge that
|
|
@@ -1790,8 +1792,10 @@ export async function switchBudget(name) {
|
|
|
1790
1792
|
// Stdio / no-session callers: per-session map has no slot for them, so the
|
|
1791
1793
|
// switch would have no effect. Refuse explicitly rather than silently no-op.
|
|
1792
1794
|
if (!sessionId) {
|
|
1793
|
-
throw new Error('Budget switch requires an MCP session.
|
|
1794
|
-
'
|
|
1795
|
+
throw new Error('Budget switch requires an MCP session. This caller has none, which means a CLI script or an ' +
|
|
1796
|
+
'internal startup path rather than a client: both HTTP and stdio clients get a session (#348). ' +
|
|
1797
|
+
'Such callers operate on the env-default budget; configure ACTUAL_BUDGET_SYNC_ID (or the ' +
|
|
1798
|
+
'BUDGET_n_* variants) to select a different default.');
|
|
1795
1799
|
}
|
|
1796
1800
|
const key = name.toLowerCase();
|
|
1797
1801
|
const found = budgetRegistry.get(key);
|
|
@@ -1890,7 +1894,22 @@ export async function switchBudget(name) {
|
|
|
1890
1894
|
// next withActualApi call would find no pool entry and fall back to the
|
|
1891
1895
|
// legacy init+shutdown path. Failure here is logged but not fatal: the
|
|
1892
1896
|
// legacy fallback still works, just less efficiently.
|
|
1893
|
-
|
|
1897
|
+
//
|
|
1898
|
+
// #348 EXCEPTION: never for a stdio session. stdio gained a synthetic session
|
|
1899
|
+
// id so it could switch budgets at all, but it must stay on the legacy
|
|
1900
|
+
// init/shutdown cycle. A pooled entry for stdio would be created here and then
|
|
1901
|
+
// NEVER refreshed, because connectionPool.touch() is called only from
|
|
1902
|
+
// httpServer.ts; it would expire after SESSION_IDLE_TIMEOUT_MINUTES and
|
|
1903
|
+
// cleanupIdleConnections would api.shutdown() it WITHOUT holding withApiLock,
|
|
1904
|
+
// which can tear the singleton down underneath an in-flight operation. The
|
|
1905
|
+
// legacy path re-reads getActiveBudgetConfig() on every call, so the switch
|
|
1906
|
+
// still takes effect; it just costs an init per operation, which is what stdio
|
|
1907
|
+
// already did before this ticket.
|
|
1908
|
+
if (store?.transport === 'stdio') {
|
|
1909
|
+
logger.debug(`[ADAPTER] switchBudget: session ${sessionId} is stdio; skipping pool materialisation ` +
|
|
1910
|
+
'(stdio stays on the legacy init/shutdown path by design, #348).');
|
|
1911
|
+
}
|
|
1912
|
+
else if (_skipApiInitForTests) {
|
|
1894
1913
|
setApiInitialized(true);
|
|
1895
1914
|
}
|
|
1896
1915
|
else {
|
|
@@ -227,3 +227,84 @@ export async function discoverJwksUri(issuer, allowInsecure = false, trustedHost
|
|
|
227
227
|
const { jwksUri } = await discoverOidcMetadata(issuer, allowInsecure, trustedHosts, fetchImpl);
|
|
228
228
|
return jwksUri;
|
|
229
229
|
}
|
|
230
|
+
/**
|
|
231
|
+
* #346: resolve and validate the `userinfo_endpoint` from a parsed discovery
|
|
232
|
+
* document. Network-free, and fails closed exactly like resolveJwksUri.
|
|
233
|
+
*
|
|
234
|
+
* WHY THIS IS STRICTER THAN THE JWKS RULE: SAME ORIGIN, NO ALLOWLIST.
|
|
235
|
+
*
|
|
236
|
+
* `resolveJwksUri` has an opt-in cross-origin allowlist (#254) because several
|
|
237
|
+
* real IdPs serve their key set from a different host (Google uses
|
|
238
|
+
* www.googleapis.com), and a key set is public data: the worst case of fetching
|
|
239
|
+
* it from the wrong place is that signature verification fails.
|
|
240
|
+
*
|
|
241
|
+
* The UserInfo call is not like that. We send the CALLER'S ACCESS TOKEN in an
|
|
242
|
+
* Authorization header, so a wrongly-trusted host is a token exfiltration
|
|
243
|
+
* primitive, and the identity in the response decides which budgets that caller
|
|
244
|
+
* can read. Those are different enough stakes that inheriting the JWKS allowlist
|
|
245
|
+
* would be a surprise: an operator who allowlisted a key host for #254 never
|
|
246
|
+
* agreed to have bearer tokens posted there. So this requires the endpoint to be
|
|
247
|
+
* same-origin with the issuer, with no escape hatch. If a cross-origin IdP ever
|
|
248
|
+
* needs support, it should arrive as its own explicitly-named variable, so the
|
|
249
|
+
* operator is consenting to the thing they are actually consenting to.
|
|
250
|
+
*/
|
|
251
|
+
export function resolveUserInfoUri(discoveryDoc, issuer, allowInsecure = false) {
|
|
252
|
+
const issuerUrl = assertSecureIssuer(issuer, allowInsecure);
|
|
253
|
+
if (!discoveryDoc || typeof discoveryDoc !== 'object') {
|
|
254
|
+
throw new Error('OIDC discovery document is empty or not an object');
|
|
255
|
+
}
|
|
256
|
+
const raw = discoveryDoc.userinfo_endpoint;
|
|
257
|
+
if (typeof raw !== 'string' || raw.length === 0) {
|
|
258
|
+
throw new Error('OIDC discovery document has no usable "userinfo_endpoint"; ' +
|
|
259
|
+
'AUTH_BUDGET_ACL_IDENTITY_SOURCE=userinfo cannot be used with this issuer');
|
|
260
|
+
}
|
|
261
|
+
let url;
|
|
262
|
+
try {
|
|
263
|
+
url = new URL(raw);
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
throw new Error(`OIDC "userinfo_endpoint" is not a valid URL: ${raw}`);
|
|
267
|
+
}
|
|
268
|
+
if (url.protocol !== 'https:' && !isLoopbackHost(url.hostname) && !allowInsecure) {
|
|
269
|
+
throw new Error(`OIDC "userinfo_endpoint" must use https (got ${url.protocol}); ` +
|
|
270
|
+
'refusing to send an access token over plaintext');
|
|
271
|
+
}
|
|
272
|
+
if (url.username || url.password) {
|
|
273
|
+
throw new Error('OIDC "userinfo_endpoint" must not contain embedded credentials (user:pass@host)');
|
|
274
|
+
}
|
|
275
|
+
if (url.origin.toLowerCase() !== issuerUrl.origin.toLowerCase()) {
|
|
276
|
+
throw new Error(`OIDC "userinfo_endpoint" origin (${url.origin}) does not match the issuer origin (${issuerUrl.origin}); ` +
|
|
277
|
+
'refusing to send the caller access token cross-origin. Unlike jwks_uri there is deliberately no ' +
|
|
278
|
+
'allowlist here, because this request carries a bearer token rather than fetching public keys (#346).');
|
|
279
|
+
}
|
|
280
|
+
return url.toString();
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* The discovery document resolved ONCE at startup, published for later readers.
|
|
284
|
+
*
|
|
285
|
+
* #346 code review: the ACL's UserInfo path originally called
|
|
286
|
+
* discoverOidcMetadata() itself on every cache miss, which broke two things at
|
|
287
|
+
* once. It re-fetched `/.well-known/openid-configuration` on the AUTHORIZATION
|
|
288
|
+
* path, violating this module's stated invariant that no client request triggers
|
|
289
|
+
* an outbound discovery fetch; and it passed no `trustedHosts`, so a deployment
|
|
290
|
+
* whose `jwks_uri` is legitimately cross-origin (Google: issuer
|
|
291
|
+
* accounts.google.com, keys on www.googleapis.com, exactly the case documented in
|
|
292
|
+
* docs/CONFIGURATION.md) threw inside the ACL and denied EVERY request, while the
|
|
293
|
+
* log blamed the UserInfo endpoint. That is the #343 failure shape again: a total
|
|
294
|
+
* silent denial with a misleading diagnostic.
|
|
295
|
+
*
|
|
296
|
+
* Publishing the startup result removes both. The store is written once by the
|
|
297
|
+
* server bootstrap and read thereafter; it is not a cache and is never invalidated,
|
|
298
|
+
* because the document it holds was validated under the operator's full config.
|
|
299
|
+
*/
|
|
300
|
+
let _resolvedMetadata = null;
|
|
301
|
+
export function setResolvedOidcMetadata(metadata) {
|
|
302
|
+
_resolvedMetadata = metadata;
|
|
303
|
+
}
|
|
304
|
+
export function getResolvedOidcMetadata() {
|
|
305
|
+
return _resolvedMetadata;
|
|
306
|
+
}
|
|
307
|
+
/** Test helper: forget the published document. */
|
|
308
|
+
export function _resetResolvedOidcMetadata() {
|
|
309
|
+
_resolvedMetadata = null;
|
|
310
|
+
}
|
|
@@ -13,9 +13,10 @@ import config from '../config.js';
|
|
|
13
13
|
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
|
14
14
|
import { MCPAuthTokenVerificationError } from 'mcp-auth';
|
|
15
15
|
import { createMcpAuth } from '../auth/setup.js';
|
|
16
|
-
import { discoverOidcMetadata, buildTrustedJwksHosts } from '../lib/oidc-discovery.js';
|
|
16
|
+
import { discoverOidcMetadata, buildTrustedJwksHosts, setResolvedOidcMetadata } from '../lib/oidc-discovery.js';
|
|
17
17
|
import { buildAcceptedAudiences } from '../lib/oidc-audiences.js';
|
|
18
18
|
import { budgetAclMiddleware } from '../auth/budget-acl.js';
|
|
19
|
+
import { runAclPreflight } from '../auth/budget-acl-dynamic.js';
|
|
19
20
|
import * as https from 'node:https';
|
|
20
21
|
import * as fs from 'node:fs';
|
|
21
22
|
// AsyncLocalStorage for request context: moved to src/lib/requestContext.ts
|
|
@@ -87,6 +88,10 @@ advertisedUrl) {
|
|
|
87
88
|
// www.googleapis.com). Parsed fail-fast here at the composition root and
|
|
88
89
|
// threaded as a parameter; empty (the default) keeps same-origin-only.
|
|
89
90
|
const { jwksUri, metadata: oidcMetadata } = await discoverOidcMetadata(config.OIDC_ISSUER, config.OIDC_ALLOW_INSECURE_ISSUER, buildTrustedJwksHosts(config.OIDC_JWKS_TRUSTED_HOSTS));
|
|
91
|
+
// #346: publish the startup-validated document so the ACL's UserInfo path can
|
|
92
|
+
// reuse it instead of re-fetching discovery on the authorization path with a
|
|
93
|
+
// different (empty) trusted-hosts list.
|
|
94
|
+
setResolvedOidcMetadata(oidcMetadata);
|
|
90
95
|
// #285: serve RFC 8414 OAuth Authorization Server Metadata at this origin so
|
|
91
96
|
// mcp-remote and Claude.ai's native OAuth connector can discover the IdP's
|
|
92
97
|
// token_endpoint. We already serve RFC 9728 protected-resource metadata (whose
|
|
@@ -653,6 +658,13 @@ advertisedUrl) {
|
|
|
653
658
|
console.info(`❤️ Health check: ${scheme}://${healthHost}:${port}/health`);
|
|
654
659
|
if (config.AUTH_PROVIDER === 'oidc') {
|
|
655
660
|
logger.info(`🔒 OIDC authentication enabled (JWT Bearer token required; issuer: ${config.OIDC_ISSUER})`);
|
|
661
|
+
// #345: name the userNames the ACL can match, once, at boot. Deliberately
|
|
662
|
+
// fire-and-forget INSIDE the listen callback: the server is already
|
|
663
|
+
// accepting connections, so a slow or unreachable Actual delays nothing,
|
|
664
|
+
// and runAclPreflight never rejects (it warns and returns).
|
|
665
|
+
if (config.AUTH_BUDGET_ACL_SOURCE === 'actual') {
|
|
666
|
+
void runAclPreflight();
|
|
667
|
+
}
|
|
656
668
|
}
|
|
657
669
|
else if (config.MCP_SSE_AUTHORIZATION) {
|
|
658
670
|
logger.info(`🔒 HTTP authentication enabled (static Bearer token required)`);
|
|
@@ -2,10 +2,33 @@
|
|
|
2
2
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
3
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
4
|
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
5
6
|
import logger from '../logger.js';
|
|
6
7
|
import actualToolsManager from '../actualToolsManager.js';
|
|
8
|
+
import { requestContext } from '../lib/requestContext.js';
|
|
7
9
|
export async function startStdioServer(mcp, capabilities, implementedTools, serverDescription, serverInstructions, toolSchemas, version) {
|
|
8
10
|
const toolsList = Array.isArray(implementedTools) ? implementedTools : [];
|
|
11
|
+
// #348: a synthetic session identity for this stdio process.
|
|
12
|
+
//
|
|
13
|
+
// WHY THIS EXISTS. The active budget lives in a per-session map keyed on
|
|
14
|
+
// sessionId (`sessionBudgetState` in actual-adapter.ts), read by
|
|
15
|
+
// getActiveBudgetConfig() from requestContext. Until this, stdio never entered
|
|
16
|
+
// a requestContext scope, so it had no sessionId, no slot in that map, and
|
|
17
|
+
// actual_budgets_switch refused outright rather than writing somewhere nobody
|
|
18
|
+
// reads. That refusal was correct: the alternative reports success while the
|
|
19
|
+
// budget never changes, which is the silent-no-op class of #347.
|
|
20
|
+
//
|
|
21
|
+
// WHY NOT A GLOBAL. #156 removed a process-global activeBudgetKey because in
|
|
22
|
+
// multi-user HTTP mode one user's switch changed the budget for everyone,
|
|
23
|
+
// across ACL boundaries. Reintroducing a global "just for stdio" would put that
|
|
24
|
+
// back in shared code. A per-PROCESS id is safe precisely because stdio is
|
|
25
|
+
// single-user by construction: one process, one pipe, one human. Two stdio
|
|
26
|
+
// processes get two ids and two slots, so nothing leaks between them.
|
|
27
|
+
//
|
|
28
|
+
// A uuid rather than a fixed string, so log lines are distinguishable across
|
|
29
|
+
// restarts and can never collide with an HTTP session id.
|
|
30
|
+
const stdioSessionId = `stdio-${randomUUID()}`;
|
|
31
|
+
logger.debug(`[STDIO] session id ${stdioSessionId}`);
|
|
9
32
|
const server = new Server({ name: serverDescription || 'actual-mcp-server', version: version || '0.1.0' }, { capabilities, instructions: serverInstructions });
|
|
10
33
|
// List tools handler — mirrors createServerInstance() in httpServer.ts
|
|
11
34
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
@@ -33,7 +56,29 @@ export async function startStdioServer(mcp, capabilities, implementedTools, serv
|
|
|
33
56
|
throw new Error('Tool name must be a string');
|
|
34
57
|
}
|
|
35
58
|
logger.debug(`[STDIO] tools/call ${rawName}`);
|
|
36
|
-
|
|
59
|
+
// Wrap the DISPATCH, not server.connect(). The transport invokes this handler
|
|
60
|
+
// from an I/O event, which is outside any scope established around connect(),
|
|
61
|
+
// so an AsyncLocalStorage context opened there would not be visible here.
|
|
62
|
+
// Everything downstream is awaited inside this callback, so it propagates.
|
|
63
|
+
//
|
|
64
|
+
// Only `sessionId` is placed in the store, deliberately:
|
|
65
|
+
// - no `principal`, or getActiveBudgetConfig() would attempt the #189
|
|
66
|
+
// preferred-budget restore for a caller that has no authenticated
|
|
67
|
+
// identity, and the env default would stop being authoritative.
|
|
68
|
+
// - `transport: 'stdio'` is what keeps stdio OFF the pooled path.
|
|
69
|
+
// switchBudget's slow path calls connectionPool.getConnection(), so
|
|
70
|
+
// WITHOUT this marker the first switch would silently move stdio onto
|
|
71
|
+
// the pooled branch: writes would then rely on api.sync() instead of the
|
|
72
|
+
// legacy init/shutdown cycle every existing stdio persistence behaviour
|
|
73
|
+
// is built on, and the entry would never be touched (touch() is called
|
|
74
|
+
// only from httpServer.ts), so it would expire after the idle timeout and
|
|
75
|
+
// be torn down by the cleanup sweep without the api lock, possibly
|
|
76
|
+
// mid-operation. That is a far larger change than this ticket, and it is
|
|
77
|
+
// not one we want by accident.
|
|
78
|
+
// - `allowedBudgets` is absent, so under AUTH_PROVIDER=oidc switchBudget
|
|
79
|
+
// still denies. Fail closed: a local pipe must not gain budget access an
|
|
80
|
+
// HTTP caller would need an ACL for.
|
|
81
|
+
const result = await requestContext.run({ sessionId: stdioSessionId, transport: 'stdio' }, () => mcp.executeTool(rawName, args ?? {}));
|
|
37
82
|
return {
|
|
38
83
|
content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result) }],
|
|
39
84
|
};
|
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.11.
|
|
4
|
+
"version": "0.11.5",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22.0.0",
|
|
7
7
|
"npm": ">=10.0.0"
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"verify-tools": "npm run build && node scripts/verify-tools.js",
|
|
31
31
|
"check:coverage": "node scripts/list-actual-api-methods.mjs",
|
|
32
32
|
"direct-sync": "node scripts/direct-sync/bank-sync-direct.mjs",
|
|
33
|
-
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/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/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.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/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && 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/observability_smoke.test.js && node tests/unit/residue_pattern.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/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/budget_acl_dynamic.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.test.js",
|
|
33
|
+
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/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/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.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/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/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/observability_smoke.test.js && node tests/unit/residue_pattern.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/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/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",
|
|
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",
|