actual-mcp-server 0.11.0 → 0.11.2
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 +6 -4
- package/dist/src/auth/budget-acl-dynamic.js +146 -29
- package/dist/src/auth/budget-acl.js +3 -2
- package/dist/src/config.js +47 -10
- package/dist/src/lib/actual-adapter.js +14 -1
- package/dist/src/tools/rules_create.js +26 -3
- package/dist/src/tools/rules_create_or_update.js +28 -4
- package/dist/src/tools/rules_update.js +14 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -505,7 +505,7 @@ All configuration is via environment variables. Copy `.env.example` to `.env` to
|
|
|
505
505
|
| `OIDC_SCOPES` | _(none)_ | No | Comma-separated required scopes; leave empty for Casdoor |
|
|
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
|
-
| `AUTH_BUDGET_ACL_CLAIM` | `
|
|
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
509
|
| `MCP_ENABLE_HTTPS` | `false` | No | Enable native TLS. Requires `MCP_HTTPS_CERT` and `MCP_HTTPS_KEY` |
|
|
510
510
|
| `MCP_HTTPS_CERT` | _(none)_ | No | Path to PEM certificate file (required when `MCP_ENABLE_HTTPS=true`) |
|
|
511
511
|
| `MCP_HTTPS_KEY` | _(none)_ | No | Path to PEM private key file (required when `MCP_ENABLE_HTTPS=true`) |
|
|
@@ -664,14 +664,16 @@ OIDC_SCOPES= # leave empty for Casdoor
|
|
|
664
664
|
|
|
665
665
|
```bash
|
|
666
666
|
AUTH_BUDGET_ACL_SOURCE=actual # default: static (the AUTH_BUDGET_ACL map)
|
|
667
|
-
AUTH_BUDGET_ACL_CLAIM=
|
|
667
|
+
AUTH_BUDGET_ACL_CLAIM=auto # default: mirrors Actual's own claim precedence
|
|
668
668
|
```
|
|
669
669
|
|
|
670
|
+
**How the match works.** Actual does not store your IdP's `sub`. When a user logs in, Actual resolves their identity as the first non-empty of `preferred_username`, `login`, `email`, `id`, `sub`, saves that string as the user's `userName`, and generates its own unrelated UUID as `userId`. The ACL therefore matches your token against `userName`, using that same precedence. `AUTH_BUDGET_ACL_CLAIM=auto` (the default) does exactly this; set it to a single claim name to pin one instead.
|
|
671
|
+
|
|
670
672
|
Three things to know before enabling it:
|
|
671
673
|
|
|
672
674
|
- **It requires a multi-user Actual server**, meaning one configured with `ACTUAL_OPENID_*`. On a password-only server no named users exist and every principal resolves to nothing.
|
|
673
675
|
- **Your Actual server must have been bootstrapped with a PASSWORD before OpenID was added.** This is a hard prerequisite of this MCP server generally, not just of this feature: `@actual-app/api` authenticates with a password, and an Actual server set up with OpenID from scratch refuses password login, so this server cannot connect to it at all. A password cannot be added afterwards.
|
|
674
|
-
- **
|
|
676
|
+
- **The identity is only as trustworthy as the claim behind it.** OIDC guarantees that `sub` is unique and never reassigned; `preferred_username` and `email` carry no such guarantee and are often editable by the user. This exposure is inherited from Actual, which keys its own accounts the same way, not introduced here: anyone who can set their `preferred_username` to yours can impersonate you in Actual's login too. If that matters to you, pin `AUTH_BUDGET_ACL_CLAIM` to a claim your IdP does not let users edit, and make sure that is the claim your IdP uses to populate Actual's `user_name`.
|
|
675
677
|
|
|
676
678
|
It is opt-in and fails closed: if the budget list cannot be read, or the principal matches no file, access is denied rather than granted.
|
|
677
679
|
|
|
@@ -769,4 +771,4 @@ The software is provided **as-is**, without warranty of any kind. The author acc
|
|
|
769
771
|
|
|
770
772
|
---
|
|
771
773
|
|
|
772
|
-
**Version:** 0.11.
|
|
774
|
+
**Version:** 0.11.2 | **Tool Count:** 74 (verified LibreChat-compatible)
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// instead of hand-maintained JSON, so revoking someone in Actual takes effect
|
|
5
5
|
// here without a config edit and a restart.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
7
|
+
// WHAT THE ACL JOINS ON, and the mistake this replaced (#343).
|
|
8
8
|
//
|
|
9
9
|
// `getBudgets()` returns, per remote file:
|
|
10
10
|
//
|
|
@@ -14,18 +14,25 @@
|
|
|
14
14
|
// { "userId": "a6067f04-...", "displayName": "", "userName": "", "owner": true }
|
|
15
15
|
// ]
|
|
16
16
|
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
17
|
+
// v0.11.0 matched the OIDC `sub` against `userId`, on the reasoning that `sub` is
|
|
18
|
+
// the only claim OIDC guarantees stable and unique, and that a UUID cannot collide
|
|
19
|
+
// with the blank `userName` on the service-account row. Both halves of that are
|
|
20
|
+
// true and the conclusion was still wrong: `userId` is a UUID Actual mints for
|
|
21
|
+
// ITSELF at account creation (openid.ts) and corresponds to nothing any IdP
|
|
22
|
+
// issues. Nothing ever matched, and every user was denied every budget. It failed
|
|
23
|
+
// closed, so it locked people out rather than letting them in, but the feature was
|
|
24
|
+
// inert. Reported by the requester on #317, who supplied the token dump that made
|
|
25
|
+
// it obvious.
|
|
21
26
|
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
27
|
+
// The join is therefore on `userName`, resolved via Actual's own claim precedence
|
|
28
|
+
// (see ACTUAL_IDENTITY_PRECEDENCE below).
|
|
29
|
+
//
|
|
30
|
+
// THE BLANK-ROW GUARD IS WHAT MAKES NAME MATCHING SAFE. That second row above is
|
|
31
|
+
// this server's own password/service account: it owns every file the API creates
|
|
32
|
+
// and has no `userName`, because it is not an OpenID user. A principal whose claim
|
|
33
|
+
// resolved to empty would match it and inherit `owner: true` on every file. So a
|
|
34
|
+
// blank or missing claim yields null, never '', and the matcher skips blank
|
|
35
|
+
// candidates. Those two guards are load bearing; do not relax either.
|
|
29
36
|
//
|
|
30
37
|
// WHY THE RESOLUTION RUNS UNDER AN EXPLICIT WILDCARD CONTEXT.
|
|
31
38
|
//
|
|
@@ -60,18 +67,104 @@ export function _resetDynamicAclCache() {
|
|
|
60
67
|
_cache.clear();
|
|
61
68
|
}
|
|
62
69
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
70
|
+
* Actual's OWN identity precedence, mirrored exactly (#343).
|
|
71
|
+
*
|
|
72
|
+
* From `packages/sync-server/src/accounts/openid.ts`:
|
|
73
|
+
*
|
|
74
|
+
* const identity = userInfo.preferred_username ?? userInfo.login ??
|
|
75
|
+
* userInfo.email ?? userInfo.id ?? userInfo.sub;
|
|
76
|
+
* userId = uuidv4();
|
|
77
|
+
* INSERT INTO users (id, user_name, ...) VALUES (userId, identity, ...)
|
|
78
|
+
*
|
|
79
|
+
* So `user_name` holds whichever of these the IdP supplied FIRST, and `userId`
|
|
80
|
+
* is a UUID Actual generated for itself. This list is not a preference of ours;
|
|
81
|
+
* it is the only sequence that reproduces the value Actual actually stored, and
|
|
82
|
+
* the ORDER must track upstream's or the join silently stops matching.
|
|
83
|
+
*
|
|
84
|
+
* ONE DELIBERATE DIVERGENCE. Upstream uses `??`, which stops at the first
|
|
85
|
+
* non-nullish value, including `''` and non-strings. We SKIP blank and non-string
|
|
86
|
+
* entries and keep walking. That is deliberately safer here: an IdP that emits
|
|
87
|
+
* `preferred_username: ""` makes Actual store `user_name = ''`, and stopping there
|
|
88
|
+
* would hand us an empty principal, which is precisely the value that must never
|
|
89
|
+
* be matched against the service account's blank `userName`. The cost is that in
|
|
90
|
+
* that edge case our principal and Actual's `user_name` disagree, so the user is
|
|
91
|
+
* DENIED rather than matched. Denial is the correct side to err on.
|
|
92
|
+
*
|
|
93
|
+
* SOURCE OF CLAIMS, and a real limitation. Upstream evaluates this over the
|
|
94
|
+
* UserInfo endpoint response; we evaluate it over the verified ACCESS TOKEN
|
|
95
|
+
* payload (`req.auth.claims`, set in httpServer.ts), because that is what this
|
|
96
|
+
* server has. Those two sets are not always equal: several IdPs omit
|
|
97
|
+
* `preferred_username` from access tokens unless a mapper is configured. If the
|
|
98
|
+
* winning claim here differs from the one Actual used, the join fails and the user
|
|
99
|
+
* is denied. That is why the no-match path logs WHICH claim won, and why the
|
|
100
|
+
* documentation tells operators to ensure the claim is present in the access token.
|
|
101
|
+
*/
|
|
102
|
+
export const ACTUAL_IDENTITY_PRECEDENCE = ['preferred_username', 'login', 'email', 'id', 'sub'];
|
|
103
|
+
/** Sentinel for AUTH_BUDGET_ACL_CLAIM meaning "walk the precedence above". */
|
|
104
|
+
export const AUTO_CLAIM = 'auto';
|
|
105
|
+
/**
|
|
106
|
+
* Extract the identity value to match against Actual's `userName`.
|
|
107
|
+
*
|
|
108
|
+
* WHY THIS IS NOT `sub`, despite `sub` being the right answer in general.
|
|
109
|
+
*
|
|
110
|
+
* OIDC Core guarantees only `sub` is "locally unique and never reassigned";
|
|
111
|
+
* `preferred_username` carries no such guarantee and is mutable at the IdP. Under
|
|
112
|
+
* normal circumstances keying on `sub` is simply correct, and that is what this
|
|
113
|
+
* shipped with in v0.11.0.
|
|
114
|
+
*
|
|
115
|
+
* It could never work. Actual does not store `sub` anywhere we can see: it
|
|
116
|
+
* derives the identity by the precedence above and stores THAT, alongside a UUID
|
|
117
|
+
* of its own invention. There is no field in `usersWithAccess` that a `sub` can
|
|
118
|
+
* be compared against. Matching the best-practice identifier resolved every
|
|
119
|
+
* principal to zero budgets and locked every user out (#343).
|
|
120
|
+
*
|
|
121
|
+
* So we mirror Actual. The security consequence is inherited, not introduced: an
|
|
122
|
+
* IdP that lets a user set `preferred_username` (or an unverified `email`) to
|
|
123
|
+
* another user's value can impersonate them HERE exactly as it can in Actual's own
|
|
124
|
+
* login. An operator who wants a stronger guarantee should pin a single trusted
|
|
125
|
+
* claim with AUTH_BUDGET_ACL_CLAIM and ensure their IdP populates Actual's
|
|
126
|
+
* `user_name` from that same claim.
|
|
65
127
|
*
|
|
66
128
|
* Blank is deliberately null and not '': an empty string would compare equal to
|
|
67
|
-
* the service account's
|
|
129
|
+
* the service account's blank `userName`, which carries `owner: true`. That guard
|
|
130
|
+
* is what makes name matching safe, and it is why the precedence SKIPS blank
|
|
131
|
+
* entries rather than stopping at the first one that merely exists.
|
|
68
132
|
*/
|
|
69
133
|
export function extractPrincipalValue(claims, subject, claimName) {
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
134
|
+
const read = (name) => {
|
|
135
|
+
// `sub` is normally surfaced as the token subject rather than a claim entry.
|
|
136
|
+
const raw = name === 'sub' ? (subject ?? claims['sub']) : claims[name];
|
|
137
|
+
if (typeof raw !== 'string')
|
|
138
|
+
return null;
|
|
139
|
+
// Blank-ONLY rejection. The value itself is returned UNTRIMMED, because
|
|
140
|
+
// Actual's user_name uniqueness is exact: " alice" and "alice" are two
|
|
141
|
+
// different accounts. Trimming here (and again on the candidate) would let a
|
|
142
|
+
// user who can register " alice" at the IdP match the real alice's access
|
|
143
|
+
// rows and read her budgets. Trim to decide emptiness, never to compare.
|
|
144
|
+
return raw.trim().length > 0 ? raw : null;
|
|
145
|
+
};
|
|
146
|
+
if (claimName !== AUTO_CLAIM)
|
|
147
|
+
return read(claimName);
|
|
148
|
+
for (const name of ACTUAL_IDENTITY_PRECEDENCE) {
|
|
149
|
+
const value = read(name);
|
|
150
|
+
if (value !== null)
|
|
151
|
+
return value;
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Which claim the precedence actually selected, for logging. Returns null when
|
|
157
|
+
* nothing resolved. Kept separate so the hot path stays a plain string.
|
|
158
|
+
*/
|
|
159
|
+
export function resolvedClaimName(claims, subject, claimName) {
|
|
160
|
+
if (claimName !== AUTO_CLAIM) {
|
|
161
|
+
return extractPrincipalValue(claims, subject, claimName) === null ? null : claimName;
|
|
162
|
+
}
|
|
163
|
+
for (const name of ACTUAL_IDENTITY_PRECEDENCE) {
|
|
164
|
+
if (extractPrincipalValue(claims, subject, name) !== null)
|
|
165
|
+
return name;
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
75
168
|
}
|
|
76
169
|
/**
|
|
77
170
|
* Map a getBudgets() payload to the sync IDs the given principal may access.
|
|
@@ -79,7 +172,7 @@ export function extractPrincipalValue(claims, subject, claimName) {
|
|
|
79
172
|
* Pure, so the matching rules are unit-testable against the captured live
|
|
80
173
|
* fixture without a server. Exported for that reason.
|
|
81
174
|
*/
|
|
82
|
-
export function matchAllowedFiles(files, principalValue
|
|
175
|
+
export function matchAllowedFiles(files, principalValue) {
|
|
83
176
|
if (!Array.isArray(files))
|
|
84
177
|
return [];
|
|
85
178
|
// An empty principal can never match. Belt and braces: extractPrincipalValue
|
|
@@ -87,7 +180,6 @@ export function matchAllowedFiles(files, principalValue, claimName) {
|
|
|
87
180
|
// depend on its callers for that guarantee.
|
|
88
181
|
if (!principalValue)
|
|
89
182
|
return [];
|
|
90
|
-
const matchesName = claimName !== 'sub';
|
|
91
183
|
const allowed = new Set();
|
|
92
184
|
for (const entry of files) {
|
|
93
185
|
if (!entry || typeof entry !== 'object')
|
|
@@ -104,13 +196,21 @@ export function matchAllowedFiles(files, principalValue, claimName) {
|
|
|
104
196
|
// Read the whole list, not only `owner: true`. A file shared with a
|
|
105
197
|
// household member appears with owner: false, and that is exactly the
|
|
106
198
|
// case this feature exists to honour.
|
|
107
|
-
|
|
199
|
+
//
|
|
200
|
+
// ALWAYS `userName`, never `userId` (#343). `userId` is a UUID Actual
|
|
201
|
+
// generates for itself at account creation and it corresponds to nothing
|
|
202
|
+
// any IdP issues, so comparing a token value against it can only ever
|
|
203
|
+
// return zero matches. `userName` is the field that holds the identity
|
|
204
|
+
// Actual resolved from the token.
|
|
205
|
+
const candidate = u.userName;
|
|
108
206
|
if (typeof candidate !== 'string')
|
|
109
207
|
continue;
|
|
110
|
-
|
|
111
|
-
|
|
208
|
+
// Blank check trims; the COMPARISON does not. Actual's user_name uniqueness
|
|
209
|
+
// is exact, so trimming both sides would collapse " alice" and "alice" into
|
|
210
|
+
// one identity and hand the second one's budgets to the first.
|
|
211
|
+
if (candidate.trim().length === 0)
|
|
112
212
|
continue; // never match the blank service-account row
|
|
113
|
-
if (
|
|
213
|
+
if (candidate === principalValue) {
|
|
114
214
|
allowed.add(syncId);
|
|
115
215
|
break;
|
|
116
216
|
}
|
|
@@ -125,7 +225,7 @@ export function matchAllowedFiles(files, principalValue, claimName) {
|
|
|
125
225
|
* An authorization source that opens up when the thing it queries is unreachable
|
|
126
226
|
* is worse than no authorization source at all.
|
|
127
227
|
*/
|
|
128
|
-
export async function resolveAllowedBudgetsFromActual(principalValue) {
|
|
228
|
+
export async function resolveAllowedBudgetsFromActual(principalValue, lastResolvedClaim) {
|
|
129
229
|
if (!principalValue) {
|
|
130
230
|
log.warn('dynamic ACL: no usable principal value; denying', { claim: config.AUTH_BUDGET_ACL_CLAIM });
|
|
131
231
|
return [];
|
|
@@ -143,12 +243,29 @@ export async function resolveAllowedBudgetsFromActual(principalValue) {
|
|
|
143
243
|
log.error('dynamic ACL: could not read the budget list; denying access', err);
|
|
144
244
|
return [];
|
|
145
245
|
}
|
|
146
|
-
const allowed = matchAllowedFiles(files, principalValue
|
|
246
|
+
const allowed = matchAllowedFiles(files, principalValue);
|
|
147
247
|
if (allowed.length === 0) {
|
|
248
|
+
// #343 was a TOTAL silent denial that survived two releases because the log
|
|
249
|
+
// said nothing useful. Name the claim that actually won and the userNames on
|
|
250
|
+
// offer, so the next mismatch is diagnosable from one log line instead of a
|
|
251
|
+
// debugging session. The principal itself is not logged at warn level (it is
|
|
252
|
+
// 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
|
+
: [];
|
|
148
259
|
log.warn('dynamic ACL: principal matched no budget file', {
|
|
149
|
-
|
|
260
|
+
configuredClaim: config.AUTH_BUDGET_ACL_CLAIM,
|
|
261
|
+
resolvedFromClaim: lastResolvedClaim ?? '(none)',
|
|
150
262
|
fileCount: Array.isArray(files) ? files.length : 0,
|
|
263
|
+
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.',
|
|
151
267
|
});
|
|
268
|
+
log.debug('dynamic ACL: unmatched principal', { principal: principalValue });
|
|
152
269
|
}
|
|
153
270
|
_cache.set(principalValue, { at: Date.now(), allowed });
|
|
154
271
|
return allowed;
|
|
@@ -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 } from './budget-acl-dynamic.js';
|
|
27
|
+
import { extractPrincipalValue, resolveAllowedBudgetsFromActual, resolvedClaimName } from './budget-acl-dynamic.js';
|
|
28
28
|
// ---------------------------------------------------------------------------
|
|
29
29
|
// ACL map (parsed once from env, cached)
|
|
30
30
|
// ---------------------------------------------------------------------------
|
|
@@ -157,7 +157,8 @@ async function resolveDynamicAcl(req, res, next) {
|
|
|
157
157
|
const subject = auth?.subject;
|
|
158
158
|
try {
|
|
159
159
|
const principalValue = extractPrincipalValue(claims, subject, config.AUTH_BUDGET_ACL_CLAIM);
|
|
160
|
-
const
|
|
160
|
+
const winningClaim = resolvedClaimName(claims, subject, config.AUTH_BUDGET_ACL_CLAIM);
|
|
161
|
+
const allowed = await resolveAllowedBudgetsFromActual(principalValue, winningClaim);
|
|
161
162
|
if (allowed.length > 0) {
|
|
162
163
|
req.allowedBudgets = allowed;
|
|
163
164
|
next();
|
package/dist/src/config.js
CHANGED
|
@@ -104,18 +104,45 @@ export const configSchema = z.object({
|
|
|
104
104
|
// functions when that server runs in multi-user (OpenID) mode. Defaulting to
|
|
105
105
|
// 'static' means no existing deployment changes posture on upgrade.
|
|
106
106
|
AUTH_BUDGET_ACL_SOURCE: z.enum(['static', 'actual']).default('static'),
|
|
107
|
-
// #338: which
|
|
108
|
-
// AUTH_BUDGET_ACL_SOURCE=actual. Default '
|
|
107
|
+
// #338/#343: which token claim identifies the principal when
|
|
108
|
+
// AUTH_BUDGET_ACL_SOURCE=actual. Default 'auto' walks Actual's OWN precedence
|
|
109
|
+
// (preferred_username, login, email, id, sub) and matches the result against
|
|
110
|
+
// Actual's `userName`.
|
|
109
111
|
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
112
|
+
// 'auto' is the default because it is the only setting that works out of the
|
|
113
|
+
// box. v0.11.0 defaulted to 'sub', which is the correct identifier by OIDC
|
|
114
|
+
// (the spec guarantees only `sub` is unique and never reassigned) and which
|
|
115
|
+
// could nonetheless never match: Actual stores no `sub`, it stores the
|
|
116
|
+
// precedence result in `user_name` plus a UUID of its own in `id`. See #343.
|
|
117
|
+
//
|
|
118
|
+
// Set a specific claim name to pin one trusted claim instead, which is worth
|
|
119
|
+
// doing if your IdP lets users edit `preferred_username` or does not verify
|
|
120
|
+
// `email`. Whatever you pin must be the same claim your IdP used to populate
|
|
121
|
+
// Actual's `user_name`, or nothing will match.
|
|
115
122
|
AUTH_BUDGET_ACL_CLAIM: z
|
|
116
123
|
.string()
|
|
117
|
-
.default('
|
|
118
|
-
.refine((v) => /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/.test(v), 'AUTH_BUDGET_ACL_CLAIM must be a simple claim name'),
|
|
124
|
+
.default('auto')
|
|
125
|
+
.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'),
|
|
126
|
+
})
|
|
127
|
+
// #343 UPGRADE GUARD. v0.11.0 and v0.11.1 shipped AUTH_BUDGET_ACL_CLAIM=sub as
|
|
128
|
+
// the default AND the README told operators to keep it. That combination cannot
|
|
129
|
+
// match anything: Actual stores no `sub`, so every principal resolves to zero
|
|
130
|
+
// budgets and every user gets a 403 saying only "no budget access configured".
|
|
131
|
+
//
|
|
132
|
+
// Anyone who followed that advice has `sub` pinned in their .env, where it would
|
|
133
|
+
// survive this upgrade and keep them locked out with the new default never
|
|
134
|
+
// applying. Refusing to start is deliberately louder than a warning: a config
|
|
135
|
+
// error names the problem and the fix at the moment of the upgrade, whereas the
|
|
136
|
+
// alternative is a silent, total denial that already survived two releases
|
|
137
|
+
// undetected. Scoped narrowly to the exact broken pair, so no other deployment
|
|
138
|
+
// is affected.
|
|
139
|
+
.refine((cfg) => !(cfg.AUTH_BUDGET_ACL_SOURCE === 'actual' && cfg.AUTH_BUDGET_ACL_CLAIM === 'sub'), {
|
|
140
|
+
path: ['AUTH_BUDGET_ACL_CLAIM'],
|
|
141
|
+
message: 'AUTH_BUDGET_ACL_CLAIM=sub cannot work with AUTH_BUDGET_ACL_SOURCE=actual: Actual does not store the ' +
|
|
142
|
+
'OIDC sub. It derives a user_name from preferred_username/login/email/id/sub and generates its own ' +
|
|
143
|
+
'unrelated userId, so matching sub denies every user. Use AUTH_BUDGET_ACL_CLAIM=auto (the default, ' +
|
|
144
|
+
'which mirrors that precedence), or pin a claim that your IdP also used to populate Actual user_name. ' +
|
|
145
|
+
'See #343.',
|
|
119
146
|
})
|
|
120
147
|
// When native TLS is enabled, both the cert and key paths must be provided.
|
|
121
148
|
// MCP_ENABLE_HTTPS is transformed to a boolean above, so this object-level
|
|
@@ -130,7 +157,17 @@ export const configSchema = z.object({
|
|
|
130
157
|
function getConfig() {
|
|
131
158
|
const result = configSchema.safeParse(process.env);
|
|
132
159
|
if (!result.success) {
|
|
133
|
-
|
|
160
|
+
// Print the MESSAGE, not just the variable name. Every refine in this file
|
|
161
|
+
// writes an explanation of what is wrong and how to fix it, and all of them
|
|
162
|
+
// were being discarded here, so a validation failure read as "this var is
|
|
163
|
+
// missing" even when it was present and merely invalid. Found while adding
|
|
164
|
+
// the #343 upgrade guard, whose whole purpose is to explain itself.
|
|
165
|
+
const missing = result.error.issues
|
|
166
|
+
.map((i) => {
|
|
167
|
+
const name = i.path.join('.') || '(config)';
|
|
168
|
+
return i.message && i.message !== 'Required' ? ` • ${name}: ${i.message}` : ` • ${name}`;
|
|
169
|
+
})
|
|
170
|
+
.join('\n');
|
|
134
171
|
console.error(`\n❌ Missing or invalid environment variables:\n${missing}\n\n` +
|
|
135
172
|
`Set them in a .env file in the current directory, or export them before running.\n` +
|
|
136
173
|
`Required: ACTUAL_SERVER_URL, ACTUAL_PASSWORD, ACTUAL_BUDGET_SYNC_ID\n` +
|
|
@@ -1302,7 +1302,20 @@ export async function updateRule(id, fields) {
|
|
|
1302
1302
|
const fieldsObj = fields;
|
|
1303
1303
|
const rule = {
|
|
1304
1304
|
id,
|
|
1305
|
-
|
|
1305
|
+
// #342: `??` is WRONG for stage, and only for stage. null is a MEANINGFUL
|
|
1306
|
+
// value here (Actual's normal stage), not an absent one, so
|
|
1307
|
+
// `fieldsObj.stage ?? existingRule.stage` silently discards an explicit
|
|
1308
|
+
// `stage: null` and leaves the rule where it was. That made it impossible
|
|
1309
|
+
// to move a rule OUT of 'pre' or 'post' back to the normal stage, and it
|
|
1310
|
+
// failed silently: the call still returned success.
|
|
1311
|
+
//
|
|
1312
|
+
// Decide on PRESENCE of the key instead. The tool clones its input with
|
|
1313
|
+
// JSON.parse(JSON.stringify(...)), which preserves an explicit null key, so
|
|
1314
|
+
// `in` distinguishes "not supplied" from "supplied as null" correctly.
|
|
1315
|
+
//
|
|
1316
|
+
// The other three fields keep `??` deliberately: none of them treats null
|
|
1317
|
+
// as a distinct legal value, so nullish-coalescing is the right merge there.
|
|
1318
|
+
stage: 'stage' in fieldsObj ? fieldsObj.stage : existingRule.stage,
|
|
1306
1319
|
conditionsOp: fieldsObj.conditionsOp ?? existingRule.conditionsOp,
|
|
1307
1320
|
conditions: fieldsObj.conditions ?? existingRule.conditions ?? [],
|
|
1308
1321
|
actions: fieldsObj.actions ?? existingRule.actions ?? [],
|
|
@@ -33,7 +33,29 @@ const FIELD_OPERATORS = {
|
|
|
33
33
|
'date': { type: 'date', operators: ['is', 'gte', 'lte', 'gt', 'lt'] },
|
|
34
34
|
};
|
|
35
35
|
const InputSchema = z.object({
|
|
36
|
-
|
|
36
|
+
// #342: three stages, and the default one is `null`, not a string.
|
|
37
|
+
//
|
|
38
|
+
// Verified against Actual's validator (transaction-rules.ts) and a live server:
|
|
39
|
+
// it accepts exactly 'pre', 'post' and null, and REJECTS the literal "default"
|
|
40
|
+
// with `Invalid rule stage: default`. The published API reference says
|
|
41
|
+
// pre/default/post, which is wrong in both directions (reported upstream as
|
|
42
|
+
// actualbudget/actual#8682).
|
|
43
|
+
//
|
|
44
|
+
// The default here MUST be null and must be SENT. Two traps:
|
|
45
|
+
// - Defaulting to 'pre' (what this did until #342) silently puts every
|
|
46
|
+
// MCP-created rule ahead of every rule the user made in the UI, because
|
|
47
|
+
// Actual runs stages in the order pre, default, post. No error, no warning.
|
|
48
|
+
// - Simply making it .optional() and omitting the key does NOT work: on a
|
|
49
|
+
// create the validator always runs and rejects `undefined` with
|
|
50
|
+
// `Invalid rule stage: undefined`.
|
|
51
|
+
stage: z
|
|
52
|
+
.enum(['pre', 'post'])
|
|
53
|
+
.nullable()
|
|
54
|
+
.optional()
|
|
55
|
+
.default(null)
|
|
56
|
+
.describe('When to apply the rule. null (the default) is Actual\'s normal stage, the one a rule gets in the UI ' +
|
|
57
|
+
'when no stage is chosen. "pre" runs before the default stage, "post" after. Leave unset unless you ' +
|
|
58
|
+
'specifically need the rule to out-rank or defer to the user\'s existing rules.'),
|
|
37
59
|
conditionsOp: z.enum(['and', 'or']).optional().default('and').describe('How to combine multiple conditions'),
|
|
38
60
|
conditions: z.array(ConditionSchema).describe('Array of conditions that must be met for the rule to apply'),
|
|
39
61
|
actions: z.array(ActionSchema).describe('Array of actions to perform when conditions are met'),
|
|
@@ -49,10 +71,11 @@ IMPORTANT Field Types:
|
|
|
49
71
|
- "notes", "description" (string) - for text matching. Supports: contains, matches, doesNotContain, is, isNot
|
|
50
72
|
- "amount", "date" (number/date) - supports: is, gte, lte, gt, lt
|
|
51
73
|
|
|
52
|
-
Stage
|
|
74
|
+
Stage: omit it (the normal stage, recommended), or 'pre' to run before the user's own rules, or 'post' to run after.
|
|
75
|
+
Do NOT pass "default" as a string; Actual rejects it. The normal stage is null, which is what omitting gives you.
|
|
53
76
|
Action operators: 'set', 'set-split-amount', 'link-schedule', 'append-notes'.
|
|
54
77
|
|
|
55
|
-
Example
|
|
78
|
+
Example (no stage, so the rule lands in the normal stage alongside the user's own rules): {conditionsOp: "and", conditions: [{field: "imported_payee", op: "contains", value: "Amazon"}], actions: [{op: "set", field: "category", value: "category-uuid"}]}`,
|
|
56
79
|
inputSchema: InputSchema,
|
|
57
80
|
call: async (args, _meta) => {
|
|
58
81
|
// Zod validation errors are formatted centrally by actualToolsManager (#206).
|
|
@@ -55,7 +55,17 @@ const FIELD_OPERATORS = {
|
|
|
55
55
|
'date': { type: 'date', operators: ['is', 'gte', 'lte', 'gt', 'lt'] },
|
|
56
56
|
};
|
|
57
57
|
const InputSchema = z.object({
|
|
58
|
-
|
|
58
|
+
// #342: see the long note in rules_create.ts. null is Actual's DEFAULT stage;
|
|
59
|
+
// the literal "default" is rejected by the validator. No .default() here on
|
|
60
|
+
// purpose: this tool upserts, so "not supplied" must mean "leave the matched
|
|
61
|
+
// rule's stage alone", which is decided at the merge below. The create path
|
|
62
|
+
// supplies null explicitly.
|
|
63
|
+
stage: z
|
|
64
|
+
.enum(['pre', 'post'])
|
|
65
|
+
.nullable()
|
|
66
|
+
.optional()
|
|
67
|
+
.describe('When to apply the rule. null is Actual\'s normal stage (what the UI gives a rule with no stage chosen); ' +
|
|
68
|
+
'"pre" runs before it, "post" after. On an update, omitting this leaves the existing stage unchanged.'),
|
|
59
69
|
conditionsOp: z.enum(['and', 'or']).optional().default('and').describe('How to combine multiple conditions'),
|
|
60
70
|
conditions: z.array(ConditionSchema).describe('Array of conditions that must be met'),
|
|
61
71
|
actions: z.array(ActionSchema).describe('Array of actions to perform when conditions match'),
|
|
@@ -102,7 +112,9 @@ const tool = {
|
|
|
102
112
|
|
|
103
113
|
Matching logic: a rule is considered a "match" when it has the same set of conditions (field + op + value triples) and the same conditionsOp ("and"/"or"). Condition order is irrelevant.
|
|
104
114
|
|
|
105
|
-
When a match is found: the rule's actions
|
|
115
|
+
When a match is found: the rule's actions are REPLACED with the new values. The stage is replaced ONLY if you
|
|
116
|
+
supply one; omit stage to leave the matched rule in whatever stage it is already in. On a newly created rule,
|
|
117
|
+
an omitted stage means the normal stage (null), the same one the UI assigns.
|
|
106
118
|
When no match exists: a new rule is created.
|
|
107
119
|
|
|
108
120
|
IMPORTANT Field Types:
|
|
@@ -176,7 +188,13 @@ Returns: { id, created: boolean } — created=true if new rule was created, fals
|
|
|
176
188
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
177
189
|
const merged = {
|
|
178
190
|
id: matchedRule.id,
|
|
179
|
-
|
|
191
|
+
// #342: `??` is WRONG for stage. null is a MEANINGFUL value here (Actual's
|
|
192
|
+
// default stage), not an absent one, so `ruleData.stage ?? existing` would
|
|
193
|
+
// silently discard an explicit `stage: null` and keep the old stage,
|
|
194
|
+
// making it impossible to move a rule back to the default stage. Decide on
|
|
195
|
+
// PRESENCE of the key instead. Every other field below is fine with `??`
|
|
196
|
+
// because none of them treats null as a distinct legal value.
|
|
197
|
+
stage: 'stage' in ruleData ? ruleData.stage : matchedRule.stage,
|
|
180
198
|
conditionsOp: ruleData.conditionsOp ?? matchedRule.conditionsOp,
|
|
181
199
|
conditions: ruleData.conditions ?? matchedRule.conditions ?? [],
|
|
182
200
|
actions: ruleData.actions ?? matchedRule.actions ?? [],
|
|
@@ -185,7 +203,13 @@ Returns: { id, created: boolean } — created=true if new rule was created, fals
|
|
|
185
203
|
return { id: matchedRule.id, created: false };
|
|
186
204
|
}
|
|
187
205
|
else {
|
|
188
|
-
// CREATE new rule
|
|
206
|
+
// CREATE new rule.
|
|
207
|
+
// #342: stage has no Zod default on this tool (see the schema note), so an
|
|
208
|
+
// omitted stage arrives as undefined. Actual's validator ALWAYS runs on a
|
|
209
|
+
// create and rejects undefined with `Invalid rule stage: undefined`, so the
|
|
210
|
+
// key must be present. null is the correct value: Actual's default stage.
|
|
211
|
+
if (ruleData.stage === undefined)
|
|
212
|
+
ruleData.stage = null;
|
|
189
213
|
const rawId = await rawCreateRule(ruleData);
|
|
190
214
|
return { id: normalizeToId(rawId), created: true };
|
|
191
215
|
}
|
|
@@ -28,7 +28,17 @@ const FIELD_OPERATORS = {
|
|
|
28
28
|
const InputSchema = z.object({
|
|
29
29
|
id: z.string().describe('Rule ID to update'),
|
|
30
30
|
fields: z.object({
|
|
31
|
-
|
|
31
|
+
// #342: null is Actual's DEFAULT stage; the literal "default" is rejected by
|
|
32
|
+
// its validator. Deliberately NO .default() here: this is a partial update, so
|
|
33
|
+
// omitting stage must leave the existing one alone. Actual only validates
|
|
34
|
+
// stage on an update when the key is present, so omitting it is safe (unlike
|
|
35
|
+
// on a create, where undefined is rejected).
|
|
36
|
+
stage: z
|
|
37
|
+
.enum(['pre', 'post'])
|
|
38
|
+
.nullable()
|
|
39
|
+
.optional()
|
|
40
|
+
.describe('When to apply the rule. null is Actual\'s normal stage (what the UI gives a rule with no stage chosen); ' +
|
|
41
|
+
'"pre" runs before it, "post" after. Omit to leave the rule\'s current stage unchanged.'),
|
|
32
42
|
conditionsOp: z.enum(['and', 'or']).optional().describe('How to combine multiple conditions'),
|
|
33
43
|
conditions: z.array(ConditionSchema).optional().describe('New array of conditions'),
|
|
34
44
|
actions: z.array(ActionSchema).optional().describe('New array of actions'),
|
|
@@ -45,7 +55,9 @@ IMPORTANT Field Types:
|
|
|
45
55
|
- "notes", "description" (string) - for text matching. Supports: contains, matches, doesNotContain, is, isNot
|
|
46
56
|
- "amount", "date" (number/date) - supports: is, gte, lte, gt, lt
|
|
47
57
|
|
|
48
|
-
Stage
|
|
58
|
+
Stage: omit it to leave the rule where it is. Pass null to move it to the normal stage (where UI-created
|
|
59
|
+
rules live), 'pre' to run before that stage, or 'post' to run after. Do NOT pass "default" as a string;
|
|
60
|
+
Actual rejects it.
|
|
49
61
|
Action operators: 'set', 'set-split-amount', 'link-schedule', 'append-notes'.
|
|
50
62
|
|
|
51
63
|
Do not include the rule ID in the fields object - it is provided separately.`,
|
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.2",
|
|
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",
|
|
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",
|
|
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",
|