actual-mcp-server 0.11.3 → 0.11.4

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 CHANGED
@@ -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.3 | **Tool Count:** 74 (verified LibreChat-compatible)
777
+ **Version:** 0.11.4 | **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 = Array.isArray(files)
254
- ? [...new Set(files
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: 'The value from the token did not equal any userName above. Actual derives userName from the ' +
265
- 'UserInfo response; this server reads the ACCESS TOKEN. If they differ, add a claim mapper at your ' +
266
- 'IdP or pin AUTH_BUDGET_ACL_CLAIM to a claim present in the access token.',
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 { extractPrincipalValue, resolveAllowedBudgetsFromActual, resolvedClaimName } from './budget-acl-dynamic.js';
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
- const principalValue = extractPrincipalValue(claims, subject, config.AUTH_BUDGET_ACL_CLAIM);
160
- const winningClaim = resolvedClaimName(claims, subject, config.AUTH_BUDGET_ACL_CLAIM);
161
- const allowed = await resolveAllowedBudgetsFromActual(principalValue, winningClaim);
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
+ }
@@ -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
@@ -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)`);
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.3",
4
+ "version": "0.11.4",
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/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",