actual-mcp-server 0.10.0 → 0.11.1
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 +35 -4
- package/dist/src/auth/budget-acl-dynamic.js +155 -0
- package/dist/src/auth/budget-acl.js +40 -0
- package/dist/src/config.js +21 -0
- 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
|
@@ -318,7 +318,7 @@ For Claude Desktop (stdio), restart Claude after upgrading.
|
|
|
318
318
|
| `actual_accounts_reopen` | Reopen closed account |
|
|
319
319
|
| `actual_accounts_get_balance` | Get account balance at a date |
|
|
320
320
|
|
|
321
|
-
### Transactions (
|
|
321
|
+
### Transactions (14)
|
|
322
322
|
|
|
323
323
|
**Standard (6)**
|
|
324
324
|
|
|
@@ -333,11 +333,12 @@ For Claude Desktop (stdio), restart Claude after upgrading.
|
|
|
333
333
|
|
|
334
334
|
> **Split transactions:** pass a `subtransactions` array (each child needs an `amount` in integer cents; `category` and `notes` are optional). The child amounts must sum to the parent `amount` (the server does not enforce this, so the tool does). A split parent carries no category of its own: put categories on the children. `actual_transactions_update` can edit the children of a transaction that is ALREADY a split; converting a plain transaction into a split via update is not supported (create it as a split instead). Note: `actual_transactions_import` forwards a `subtransactions` array to the API but does NOT apply the sum check, so use `actual_transactions_create` for guaranteed-balanced splits.
|
|
335
335
|
|
|
336
|
-
**Utility (
|
|
336
|
+
**Utility (2)**
|
|
337
337
|
|
|
338
338
|
| Tool | Description |
|
|
339
339
|
|------|-------------|
|
|
340
340
|
| `actual_transactions_uncategorized` | Summary of uncategorized transactions (totalCount, totalAmount, per-account breakdown); pass `includeTransactions:true` for paginated rows |
|
|
341
|
+
| `actual_transactions_update_batch` | Apply many transaction updates in ONE call (`updates: [{ id, fields }]`); returns per-item success and failure counts |
|
|
341
342
|
|
|
342
343
|
**Exclusive ActualQL-powered (6)**, unique to this MCP server
|
|
343
344
|
|
|
@@ -413,10 +414,23 @@ For Claude Desktop (stdio), restart Claude after upgrading.
|
|
|
413
414
|
| `actual_budgets_export` | Export the active budget as a `.zip` into `ACTUAL_EXPORT_DIR`; returns path, byte size and sha256, never the file contents |
|
|
414
415
|
| `actual_budgets_import` | Restore a budget from an Actual `.zip` or a YNAB4/YNAB5 export. **Destructive:** the budget id comes from the archive, so re-importing an export *replaces* that budget's data rather than making a copy. Also loads the imported budget, changing the session's active budget |
|
|
415
416
|
|
|
416
|
-
### Rules (
|
|
417
|
+
### Rules (5)
|
|
417
418
|
|
|
418
419
|
`actual_rules_get` · `actual_rules_create` · `actual_rules_update` · `actual_rules_delete`
|
|
419
420
|
|
|
421
|
+
| Tool | Description |
|
|
422
|
+
|------|-------------|
|
|
423
|
+
| `actual_rules_create_or_update` | Idempotent upsert: create a rule, or update the existing one that matches the same conditions, in a single call. Use this instead of get-then-create when re-running a categorisation setup, so repeated runs do not pile up duplicate rules |
|
|
424
|
+
|
|
425
|
+
### Schedules (4)
|
|
426
|
+
|
|
427
|
+
| Tool | Description |
|
|
428
|
+
|------|-------------|
|
|
429
|
+
| `actual_schedules_get` | List scheduled transactions, with their next occurrence date |
|
|
430
|
+
| `actual_schedules_create` | Create a schedule (one-off or recurring); recurrence is a typed config, see `src/lib/schemas/recur.ts` |
|
|
431
|
+
| `actual_schedules_update` | Update an existing schedule's amount, payee, account, or recurrence |
|
|
432
|
+
| `actual_schedules_delete` | Delete a schedule |
|
|
433
|
+
|
|
420
434
|
### Advanced Query & Sync (2)
|
|
421
435
|
|
|
422
436
|
| Tool | Description |
|
|
@@ -490,6 +504,8 @@ All configuration is via environment variables. Copy `.env.example` to `.env` to
|
|
|
490
504
|
| `OIDC_JWKS_TRUSTED_HOSTS` | _(none)_ | No | Opt-in cross-origin JWKS hosts, comma-separated `host` or `host:port` (#254). For IdPs whose `jwks_uri` lives on another host, e.g. Google needs `www.googleapis.com`. Exact match, no wildcards; empty default keeps same-origin-only |
|
|
491
505
|
| `OIDC_SCOPES` | _(none)_ | No | Comma-separated required scopes; leave empty for Casdoor |
|
|
492
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
|
+
| `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` | `sub` | No | Which OIDC claim is matched against Actual's user identity when the source is `actual`. `sub` matches Actual's `userId` and is strongly preferred; matching a name claim is riskier |
|
|
493
509
|
| `MCP_ENABLE_HTTPS` | `false` | No | Enable native TLS. Requires `MCP_HTTPS_CERT` and `MCP_HTTPS_KEY` |
|
|
494
510
|
| `MCP_HTTPS_CERT` | _(none)_ | No | Path to PEM certificate file (required when `MCP_ENABLE_HTTPS=true`) |
|
|
495
511
|
| `MCP_HTTPS_KEY` | _(none)_ | No | Path to PEM private key file (required when `MCP_ENABLE_HTTPS=true`) |
|
|
@@ -644,6 +660,21 @@ OIDC_SCOPES= # leave empty for Casdoor
|
|
|
644
660
|
- `GET /.well-known/oauth-protected-resource` (RFC 9728): identifies this server as a protected resource and points at your `OIDC_ISSUER` as the authorization server.
|
|
645
661
|
- `GET /.well-known/oauth-authorization-server` (RFC 8414, #285): the authorization server metadata (its `authorization_endpoint` / `token_endpoint` / `registration_endpoint`), re-served from your IdP's own OpenID discovery document. This is here because several clients look for it on the resource-server origin, and some IdPs (e.g. Authentik) do not expose it where those clients look. It is fetched once at startup and served verbatim, exposes only endpoints your IdP already publishes publicly, and requires no authentication (a client reads it before it has a token). No extra configuration is needed; it is absent when `AUTH_PROVIDER` is not `oidc`.
|
|
646
662
|
|
|
663
|
+
**Where the per-user budget ACL comes from.** By default the ACL is the `AUTH_BUDGET_ACL` map you maintain by hand. Since v0.10.x it can instead be derived from the Actual server's own per-file access list, so granting or revoking someone in Actual takes effect here without a config edit and a restart:
|
|
664
|
+
|
|
665
|
+
```bash
|
|
666
|
+
AUTH_BUDGET_ACL_SOURCE=actual # default: static (the AUTH_BUDGET_ACL map)
|
|
667
|
+
AUTH_BUDGET_ACL_CLAIM=sub # default: sub, matched against Actual's userId
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
Three things to know before enabling it:
|
|
671
|
+
|
|
672
|
+
- **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
|
+
- **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
|
+
- **Keep `AUTH_BUDGET_ACL_CLAIM=sub` unless you have a specific reason not to.** `sub` is matched against Actual's stable `userId`. Matching a name-like claim instead is riskier: this server's own service account appears in every file's access list as the owner with a blank `userName`, so a principal whose name claim resolves to empty would match it.
|
|
675
|
+
|
|
676
|
+
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
|
+
|
|
647
678
|
See [AI Client Setup, OIDC](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-multi-user) for `AUTH_BUDGET_ACL` format and Casdoor notes.
|
|
648
679
|
|
|
649
680
|
---
|
|
@@ -738,4 +769,4 @@ The software is provided **as-is**, without warranty of any kind. The author acc
|
|
|
738
769
|
|
|
739
770
|
---
|
|
740
771
|
|
|
741
|
-
**Version:** 0.
|
|
772
|
+
**Version:** 0.11.1 | **Tool Count:** 74 (verified LibreChat-compatible)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// src/auth/budget-acl-dynamic.ts
|
|
2
|
+
//
|
|
3
|
+
// #338: derive the budget ACL from the Actual server's own per-file access list
|
|
4
|
+
// instead of hand-maintained JSON, so revoking someone in Actual takes effect
|
|
5
|
+
// here without a config edit and a restart.
|
|
6
|
+
//
|
|
7
|
+
// WHY userId AND NOT userName, verified against a live multi-user server.
|
|
8
|
+
//
|
|
9
|
+
// `getBudgets()` returns, per remote file:
|
|
10
|
+
//
|
|
11
|
+
// "owner": "a6067f04-...",
|
|
12
|
+
// "usersWithAccess": [
|
|
13
|
+
// { "userId": "5311397e-...", "displayName": "Alice", "userName": "alice", "owner": false },
|
|
14
|
+
// { "userId": "a6067f04-...", "displayName": "", "userName": "", "owner": true }
|
|
15
|
+
// ]
|
|
16
|
+
//
|
|
17
|
+
// That second row is THIS SERVER'S OWN service account. `@actual-app/api`
|
|
18
|
+
// authenticates with a password, that password identity owns every file the API
|
|
19
|
+
// creates, and it has no userName because it is not an OpenID user. So a blank
|
|
20
|
+
// `userName` is present in every file's access list on every deployment.
|
|
21
|
+
//
|
|
22
|
+
// A name-based match therefore has a live escalation path: a principal whose
|
|
23
|
+
// configured claim is missing or empty would match the blank row, which carries
|
|
24
|
+
// `owner: true`, and be granted access to every file on the server. Matching
|
|
25
|
+
// UUIDs makes that unrepresentable, because no IdP subject equals the service
|
|
26
|
+
// account's UUID. AUTH_BUDGET_ACL_CLAIM still allows name matching for
|
|
27
|
+
// deployments that need it, and the empty-principal guard below is what keeps
|
|
28
|
+
// that option from reintroducing the hole.
|
|
29
|
+
//
|
|
30
|
+
// WHY THE RESOLUTION RUNS UNDER AN EXPLICIT WILDCARD CONTEXT.
|
|
31
|
+
//
|
|
32
|
+
// Resolving the ACL requires `adapter.getBudgets()`, and every adapter entry
|
|
33
|
+
// point runs `_enforceBudgetAcl`, which refuses when the request context has no
|
|
34
|
+
// allowedBudgets. Resolving the ACL would therefore be blocked by the ACL it is
|
|
35
|
+
// trying to compute. We break that circularity by running ONLY this one call
|
|
36
|
+
// inside `requestContext.run({ allowedBudgets: ['*'] }, ...)`.
|
|
37
|
+
//
|
|
38
|
+
// That is sound rather than a loophole: `getBudgets()` lists budget FILES
|
|
39
|
+
// (name, id, who may access them). It reads no budget CONTENTS: no accounts, no
|
|
40
|
+
// transactions, no balances. The wildcard is scoped to this single listing call
|
|
41
|
+
// and never leaks into the caller's context, because AsyncLocalStorage restores
|
|
42
|
+
// the previous store when the callback returns.
|
|
43
|
+
import config from '../config.js';
|
|
44
|
+
import adapter from '../lib/actual-adapter.js';
|
|
45
|
+
import { requestContext } from '../lib/requestContext.js';
|
|
46
|
+
import { createModuleLogger } from '../lib/loggerFactory.js';
|
|
47
|
+
const log = createModuleLogger('ACL');
|
|
48
|
+
/**
|
|
49
|
+
* Cache of principal to allowed sync IDs.
|
|
50
|
+
*
|
|
51
|
+
* Bounded by TTL rather than size: the key space is the set of authenticated
|
|
52
|
+
* principals, which is small and operator-controlled. A short TTL is what makes
|
|
53
|
+
* a revocation in Actual take effect here promptly; without it this feature
|
|
54
|
+
* would reintroduce exactly the staleness it exists to remove.
|
|
55
|
+
*/
|
|
56
|
+
const CACHE_TTL_MS = 60_000;
|
|
57
|
+
const _cache = new Map();
|
|
58
|
+
/** Clear the resolver cache (test helper, and used when config is re-read). */
|
|
59
|
+
export function _resetDynamicAclCache() {
|
|
60
|
+
_cache.clear();
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Extract the identity value to match on, from the claim named by
|
|
64
|
+
* AUTH_BUDGET_ACL_CLAIM. Returns null when the claim is absent or blank.
|
|
65
|
+
*
|
|
66
|
+
* Blank is deliberately null and not '': an empty string would compare equal to
|
|
67
|
+
* the service account's empty userName. See the header note.
|
|
68
|
+
*/
|
|
69
|
+
export function extractPrincipalValue(claims, subject, claimName) {
|
|
70
|
+
const raw = claimName === 'sub' ? (subject ?? claims['sub']) : claims[claimName];
|
|
71
|
+
if (typeof raw !== 'string')
|
|
72
|
+
return null;
|
|
73
|
+
const trimmed = raw.trim();
|
|
74
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Map a getBudgets() payload to the sync IDs the given principal may access.
|
|
78
|
+
*
|
|
79
|
+
* Pure, so the matching rules are unit-testable against the captured live
|
|
80
|
+
* fixture without a server. Exported for that reason.
|
|
81
|
+
*/
|
|
82
|
+
export function matchAllowedFiles(files, principalValue, claimName) {
|
|
83
|
+
if (!Array.isArray(files))
|
|
84
|
+
return [];
|
|
85
|
+
// An empty principal can never match. Belt and braces: extractPrincipalValue
|
|
86
|
+
// already returns null for blank, but this function is exported and must not
|
|
87
|
+
// depend on its callers for that guarantee.
|
|
88
|
+
if (!principalValue)
|
|
89
|
+
return [];
|
|
90
|
+
const matchesName = claimName !== 'sub';
|
|
91
|
+
const allowed = new Set();
|
|
92
|
+
for (const entry of files) {
|
|
93
|
+
if (!entry || typeof entry !== 'object')
|
|
94
|
+
continue;
|
|
95
|
+
// groupId, NOT cloudFileId. See the BudgetFileEntry note: groupId is the
|
|
96
|
+
// "Sync ID" that _enforceBudgetAcl compares against.
|
|
97
|
+
const syncId = entry.groupId;
|
|
98
|
+
const access = entry.usersWithAccess;
|
|
99
|
+
if (!syncId || !Array.isArray(access))
|
|
100
|
+
continue;
|
|
101
|
+
for (const u of access) {
|
|
102
|
+
if (!u || typeof u !== 'object')
|
|
103
|
+
continue;
|
|
104
|
+
// Read the whole list, not only `owner: true`. A file shared with a
|
|
105
|
+
// household member appears with owner: false, and that is exactly the
|
|
106
|
+
// case this feature exists to honour.
|
|
107
|
+
const candidate = matchesName ? u.userName : u.userId;
|
|
108
|
+
if (typeof candidate !== 'string')
|
|
109
|
+
continue;
|
|
110
|
+
const trimmed = candidate.trim();
|
|
111
|
+
if (trimmed.length === 0)
|
|
112
|
+
continue; // never match the blank service-account row
|
|
113
|
+
if (trimmed === principalValue) {
|
|
114
|
+
allowed.add(syncId);
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return [...allowed];
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Resolve the allowed sync IDs for a principal from the Actual server.
|
|
123
|
+
*
|
|
124
|
+
* FAILS CLOSED. Every error path returns [] (no access) rather than ['*'].
|
|
125
|
+
* An authorization source that opens up when the thing it queries is unreachable
|
|
126
|
+
* is worse than no authorization source at all.
|
|
127
|
+
*/
|
|
128
|
+
export async function resolveAllowedBudgetsFromActual(principalValue) {
|
|
129
|
+
if (!principalValue) {
|
|
130
|
+
log.warn('dynamic ACL: no usable principal value; denying', { claim: config.AUTH_BUDGET_ACL_CLAIM });
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
const cached = _cache.get(principalValue);
|
|
134
|
+
if (cached && Date.now() - cached.at < CACHE_TTL_MS)
|
|
135
|
+
return cached.allowed;
|
|
136
|
+
let files;
|
|
137
|
+
try {
|
|
138
|
+
// The scoped wildcard described in the header. Nothing outside this call
|
|
139
|
+
// observes it.
|
|
140
|
+
files = await requestContext.run({ allowedBudgets: ['*'] }, () => adapter.getBudgets());
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
log.error('dynamic ACL: could not read the budget list; denying access', err);
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
const allowed = matchAllowedFiles(files, principalValue, config.AUTH_BUDGET_ACL_CLAIM);
|
|
147
|
+
if (allowed.length === 0) {
|
|
148
|
+
log.warn('dynamic ACL: principal matched no budget file', {
|
|
149
|
+
claim: config.AUTH_BUDGET_ACL_CLAIM,
|
|
150
|
+
fileCount: Array.isArray(files) ? files.length : 0,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
_cache.set(principalValue, { at: Date.now(), allowed });
|
|
154
|
+
return allowed;
|
|
155
|
+
}
|
|
@@ -24,6 +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
28
|
// ---------------------------------------------------------------------------
|
|
28
29
|
// ACL map (parsed once from env, cached)
|
|
29
30
|
// ---------------------------------------------------------------------------
|
|
@@ -121,6 +122,12 @@ export function budgetAclMiddleware(req, res, next) {
|
|
|
121
122
|
next();
|
|
122
123
|
return;
|
|
123
124
|
}
|
|
125
|
+
// #338: dynamic source. Deliberately a separate branch so the static path
|
|
126
|
+
// below is unreachable-by-default and byte-for-byte what it was before.
|
|
127
|
+
if (config.AUTH_BUDGET_ACL_SOURCE === 'actual') {
|
|
128
|
+
void resolveDynamicAcl(req, res, next);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
124
131
|
const allowed = getAllowedBudgets(req);
|
|
125
132
|
if (allowed.includes('*') || allowed.length > 0) {
|
|
126
133
|
// Expose allowed budgets for use in tools
|
|
@@ -133,6 +140,39 @@ export function budgetAclMiddleware(req, res, next) {
|
|
|
133
140
|
logger.warn(`[ACL] Access denied for '${identity}': no permitted budgets in ACL`);
|
|
134
141
|
res.status(403).json({ error: 'Forbidden: no budget access configured for this user' });
|
|
135
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* #338: the async half of the middleware, for AUTH_BUDGET_ACL_SOURCE=actual.
|
|
145
|
+
*
|
|
146
|
+
* Split out rather than making budgetAclMiddleware itself async, because an
|
|
147
|
+
* async Express handler that rejects before Express 5's error plumbing sees it
|
|
148
|
+
* would hang the request. Here every path either calls next() or sends a
|
|
149
|
+
* response, and the catch is exhaustive, so a rejection cannot escape.
|
|
150
|
+
*
|
|
151
|
+
* The upstream read is cached per principal inside the resolver, so this is not
|
|
152
|
+
* an Actual API call per request.
|
|
153
|
+
*/
|
|
154
|
+
async function resolveDynamicAcl(req, res, next) {
|
|
155
|
+
const auth = req.auth;
|
|
156
|
+
const claims = (auth?.claims ?? {});
|
|
157
|
+
const subject = auth?.subject;
|
|
158
|
+
try {
|
|
159
|
+
const principalValue = extractPrincipalValue(claims, subject, config.AUTH_BUDGET_ACL_CLAIM);
|
|
160
|
+
const allowed = await resolveAllowedBudgetsFromActual(principalValue);
|
|
161
|
+
if (allowed.length > 0) {
|
|
162
|
+
req.allowedBudgets = allowed;
|
|
163
|
+
next();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
logger.warn(`[ACL] Access denied for '${subject ?? 'unknown'}': no budget files grant this principal access`);
|
|
167
|
+
res.status(403).json({ error: 'Forbidden: no budget access configured for this user' });
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
// Fail closed. A resolver fault must never fall through to the static path
|
|
171
|
+
// or to an unrestricted context.
|
|
172
|
+
logger.error('[ACL] Dynamic ACL resolution failed; denying access', err);
|
|
173
|
+
res.status(403).json({ error: 'Forbidden: budget access could not be determined' });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
136
176
|
/** Reset cached state (test helper). */
|
|
137
177
|
export function _resetAclForTests() {
|
|
138
178
|
_aclMap = null;
|
package/dist/src/config.js
CHANGED
|
@@ -95,6 +95,27 @@ export const configSchema = z.object({
|
|
|
95
95
|
// Example: {"alice@example.com":["budget-1"],"group:admin":["*"]}
|
|
96
96
|
// Leave unset to allow all authenticated users to access all budgets.
|
|
97
97
|
AUTH_BUDGET_ACL: z.string().optional(),
|
|
98
|
+
// #338: where the budget ACL comes from.
|
|
99
|
+
// 'static' (default) = AUTH_BUDGET_ACL above, exactly as before.
|
|
100
|
+
// 'actual' = derived from the Actual server's own per-file access
|
|
101
|
+
// list (`usersWithAccess` on getBudgets()).
|
|
102
|
+
// Opt-in on purpose. Two reasons: it moves the authorization source from local
|
|
103
|
+
// reviewable config to data the UPSTREAM server returns at runtime, and it only
|
|
104
|
+
// functions when that server runs in multi-user (OpenID) mode. Defaulting to
|
|
105
|
+
// 'static' means no existing deployment changes posture on upgrade.
|
|
106
|
+
AUTH_BUDGET_ACL_SOURCE: z.enum(['static', 'actual']).default('static'),
|
|
107
|
+
// #338: which OIDC claim is matched against Actual's user identity when
|
|
108
|
+
// AUTH_BUDGET_ACL_SOURCE=actual. Default 'sub', matched against `userId`.
|
|
109
|
+
//
|
|
110
|
+
// Do NOT casually change this to a name-like claim. Verified against a live
|
|
111
|
+
// multi-user server: the MCP server's own service account appears in EVERY
|
|
112
|
+
// file's usersWithAccess with `owner: true` and a BLANK `userName`. A
|
|
113
|
+
// name-based match whose claim resolves to empty would match that row and gain
|
|
114
|
+
// owner access to every file. Matching UUIDs makes that unrepresentable.
|
|
115
|
+
AUTH_BUDGET_ACL_CLAIM: z
|
|
116
|
+
.string()
|
|
117
|
+
.default('sub')
|
|
118
|
+
.refine((v) => /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/.test(v), 'AUTH_BUDGET_ACL_CLAIM must be a simple claim name'),
|
|
98
119
|
})
|
|
99
120
|
// When native TLS is enabled, both the cert and key paths must be provided.
|
|
100
121
|
// MCP_ENABLE_HTTPS is transformed to a boolean above, so this object-level
|
|
@@ -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.
|
|
4
|
+
"version": "0.11.1",
|
|
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",
|
|
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",
|