actual-mcp-server 0.22.28 → 0.22.37
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 +16 -15
- package/dist/src/actualConnection.js +4 -0
- package/dist/src/actualToolsManager.js +2 -0
- package/dist/src/auth/budget-acl-parse.js +82 -0
- package/dist/src/auth/budget-acl.js +71 -36
- package/dist/src/config.js +29 -1
- package/dist/src/lib/ActualConnectionPool.js +91 -6
- package/dist/src/lib/actual-adapter.js +171 -4
- package/dist/src/lib/schemas/common.js +1 -1
- package/dist/src/lib/schemas/recur.js +36 -3
- package/dist/src/lib/schemas/schedule-amount.js +41 -0
- package/dist/src/lib/tool-annotations.js +2 -0
- package/dist/src/lib/toolsets.js +2 -0
- package/dist/src/lib/transportErrorLogger.js +69 -2
- package/dist/src/server/httpServer.js +61 -18
- package/dist/src/tools/index.js +1 -0
- package/dist/src/tools/schedules_create.js +11 -3
- package/dist/src/tools/schedules_update.js +13 -4
- package/dist/src/tools/transactions_merge.js +26 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -24,13 +24,13 @@ Actual MCP Server is a [Model Context Protocol](https://modelcontextprotocol.io/
|
|
|
24
24
|
┌─────────────┐ MCP/HTTP ┌──────────────────┐ Actual API ┌──────────────┐
|
|
25
25
|
│ LibreChat │ ◄───────────► │ Actual MCP │ ◄───────────► │ Actual │
|
|
26
26
|
│ LobeChat │ │ Server │ │ Budget │
|
|
27
|
-
│ (remote) │ │ (
|
|
27
|
+
│ (remote) │ │ (84 tools) │ │ Server │
|
|
28
28
|
└─────────────┘ └──────────────────┘ └──────────────┘
|
|
29
29
|
|
|
30
30
|
┌─────────────┐ MCP/stdio ┌──────────────────┐ Actual API ┌──────────────┐
|
|
31
31
|
│ Claude │ ◄───────────► │ Actual MCP │ ◄───────────► │ Actual │
|
|
32
32
|
│ Desktop │ │ Server │ │ Budget │
|
|
33
|
-
│ (local) │ │ (
|
|
33
|
+
│ (local) │ │ (84 tools) │ │ Server │
|
|
34
34
|
└─────────────┘ └──────────────────┘ └──────────────┘
|
|
35
35
|
```
|
|
36
36
|
|
|
@@ -38,14 +38,14 @@ Actual MCP Server is a [Model Context Protocol](https://modelcontextprotocol.io/
|
|
|
38
38
|
|
|
39
39
|
Most Actual Budget MCP implementations are simple stdio bridges designed for single-user, local use with Claude Desktop. This project goes further:
|
|
40
40
|
|
|
41
|
-
- **
|
|
41
|
+
- **84 tools, the most comprehensive coverage available.** Accounts, transactions, categories, payees, tags, notes, rules, budgets, batch operations, bank sync, and more. Covers the reachable Actual Budget API with no genuine gaps.
|
|
42
42
|
- **HTTP and stdio transport.** Runs as a real remote server for LibreChat/LobeChat (`--http`), or as a direct local process for Claude Desktop (`--stdio`). No Docker or HTTP server is needed for local use.
|
|
43
43
|
- **6 exclusive ActualQL-powered tools.** Search and summarise transactions by month, amount, category, or payee using Actual Budget's native query engine. Aggregated results, no raw data dumped into the AI context window.
|
|
44
44
|
- **Multi-budget switching at runtime.** Configure multiple budget files and let the AI switch between them mid-conversation with `actual_budgets_switch`. Works on both transports: HTTP keys the active budget to the MCP session, and stdio (Claude Desktop, Claude Code, Cursor) gets a synthetic per-process session so a switch is scoped to that process rather than shared globally (#348).
|
|
45
45
|
- **Multi-user ready with OIDC.** Secure every session with JWKS-validated JWTs and per-user budget ACLs. No shared tokens required.
|
|
46
46
|
- **Production-grade reliability on both transports.** HTTP connection pooling (up to 15 concurrent sessions), and a long-lived stdio process that logs in ONCE and reuses that connection for every tool call instead of re-authenticating per call, so Claude Desktop and Claude Code stay fast and a burst of calls no longer risks a per-call login storm against the upstream limiter. Automatic retry with exponential backoff, and a full test suite (unit + E2E + integration).
|
|
47
47
|
|
|
48
|
-
> **Verified working** with [LibreChat](https://www.librechat.ai/), [LobeChat](https://lobehub.com/home), and [Claude Desktop](https://claude.ai/download). All
|
|
48
|
+
> **Verified working** with [LibreChat](https://www.librechat.ai/), [LobeChat](https://lobehub.com/home), and [Claude Desktop](https://claude.ai/download). All 84 tools tested end-to-end. Any MCP-compatible client should work.
|
|
49
49
|
|
|
50
50
|
---
|
|
51
51
|
|
|
@@ -182,7 +182,7 @@ Add to `claude_desktop_config.json` (see [docs/guides/MCP_CLIENTS_SETUP.md](docs
|
|
|
182
182
|
}
|
|
183
183
|
```
|
|
184
184
|
|
|
185
|
-
> **No token needed.** stdio runs as a local process owned by your user. The transport itself is the security boundary. All
|
|
185
|
+
> **No token needed.** stdio runs as a local process owned by your user. The transport itself is the security boundary. All 84 tools are available.
|
|
186
186
|
>
|
|
187
187
|
> **`MCP_BRIDGE_DATA_DIR` should be an absolute path.** Without one, the data directory resolves relative to wherever the client spawns the process, which can be unpredictable. The directory is created automatically on first run.
|
|
188
188
|
|
|
@@ -308,7 +308,7 @@ For Claude Desktop (stdio), restart Claude after upgrading.
|
|
|
308
308
|
|
|
309
309
|
## Available Tools
|
|
310
310
|
|
|
311
|
-
**
|
|
311
|
+
**84 tools** across all categories. All tools use the `actual_<category>_<action>` naming convention.
|
|
312
312
|
|
|
313
313
|
### Accounts (12)
|
|
314
314
|
|
|
@@ -327,9 +327,9 @@ For Claude Desktop (stdio), restart Claude after upgrading.
|
|
|
327
327
|
| `actual_account_groups_update` | Rename an account group or change its sidebar position. A group that does not exist is refused, writing nothing |
|
|
328
328
|
| `actual_account_groups_delete` | Delete an account group. Its accounts are NOT deleted, they become ungrouped |
|
|
329
329
|
|
|
330
|
-
### Transactions (
|
|
330
|
+
### Transactions (17)
|
|
331
331
|
|
|
332
|
-
**Standard (
|
|
332
|
+
**Standard (7)**
|
|
333
333
|
|
|
334
334
|
| Tool | Description |
|
|
335
335
|
|------|-------------|
|
|
@@ -339,6 +339,7 @@ For Claude Desktop (stdio), restart Claude after upgrading.
|
|
|
339
339
|
| `actual_transactions_import` | Import and reconcile transactions |
|
|
340
340
|
| `actual_transactions_update` | Update a transaction, split a plain one in place, or edit the children of an existing split via `subtransactions` |
|
|
341
341
|
| `actual_transactions_delete` | Delete a transaction |
|
|
342
|
+
| `actual_transactions_merge` | Merge two same-account, same-amount transactions (e.g. a bank-imported duplicate and a manual entry). Upstream keeps one (the imported one first) and DELETES the other, filling only the kept row's empty payee, category, notes and schedule and its false cleared and reconciled flags; irreversible |
|
|
342
343
|
|
|
343
344
|
> **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` with `subtransactions` edits the children of a transaction that is already a split, or splits a plain transaction IN PLACE: it keeps its id, `imported_id` and cleared state, so a bank-synced transaction can be split without breaking import matching. Its own category is cleared, and a child that names no category inherits it. A split child cannot itself be split. 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.
|
|
344
345
|
|
|
@@ -524,8 +525,8 @@ All configuration is via environment variables. Copy `.env.example` to `.env` to
|
|
|
524
525
|
| `OIDC_JWKS_URI` | _(none)_ | No | Direct JWKS URL that bypasses OpenID discovery (#462), for issuers with no discovery document (Cloudflare Access: `https://<team>.cloudflareaccess.com/cdn-cgi/access/certs`). Same https, credentials and origin-or-trusted-host rules as a discovered `jwks_uri`, plus no query or fragment. When set, `/.well-known/oauth-authorization-server` is not served and `AUTH_BUDGET_ACL_IDENTITY_SOURCE=userinfo` is refused |
|
|
525
526
|
| `OIDC_TOKEN_SOURCE` | `authorization` | No | Where the bearer JWT is read from (#462): `authorization` (default) or `cf-access-jwt-assertion` for an MCP server behind Cloudflare Access with Managed OAuth (the edge forwards the signed Access JWT in that header; `Authorization` is then never consulted, `OIDC_SCOPES` must be empty, `OIDC_SCOPES_SUPPORTED` is allowed, and the origin must be reachable only via Cloudflare). See the Cloudflare recipe in `docs/guides/AI_CLIENT_SETUP.md` |
|
|
526
527
|
| `OIDC_SCOPES` | _(none)_ | No | Comma-separated required (enforced) scopes, also advertised. Leave empty when the IdP's JWTs carry no `scope` claim (Cloudflare Access for SaaS, or Casdoor when the client requests no scope) |
|
|
527
|
-
| `OIDC_SCOPES_SUPPORTED` | _(none)_ | No | Comma-separated scopes advertised in `scopes_supported` but not enforced. Use when the IdP's JWTs carry no `scope` claim but the client needs a scope such as `offline_access` advertised (e.g. Gemini with Cloudflare Access for SaaS). Required scopes from `OIDC_SCOPES` are always advertised too |
|
|
528
|
-
| `AUTH_BUDGET_ACL` | _(none)_ | No | Per-user budget ACL; see [AI Client Setup](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-multi-user) |
|
|
528
|
+
| `OIDC_SCOPES_SUPPORTED` | _(none)_ | No | Comma-separated scopes advertised in `scopes_supported` but not enforced. Use when the IdP's JWTs carry no `scope` claim but the client needs a scope such as `offline_access` advertised (e.g. Gemini with Cloudflare Access for SaaS). Required scopes from `OIDC_SCOPES` are always advertised too. Advertising `offline_access` unlocks a refresh token this server cannot revoke: see [Limiting a leaked refresh token](docs/SECURITY_AND_PRIVACY.md#limiting-a-leaked-refresh-token-488) |
|
|
529
|
+
| `AUTH_BUDGET_ACL` | _(none)_ | No | Per-user budget ACL; see [AI Client Setup](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-multi-user). Validated at startup: a value that is not a JSON object of string arrays refuses to start (#551). Unset or `{}` means no restrictions |
|
|
529
530
|
| `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 |
|
|
530
531
|
| `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 |
|
|
531
532
|
| `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 |
|
|
@@ -556,7 +557,7 @@ All configuration is via environment variables. Copy `.env.example` to `.env` to
|
|
|
556
557
|
|
|
557
558
|
### Reducing the tool surface
|
|
558
559
|
|
|
559
|
-
All
|
|
560
|
+
All 84 tools are published by default. Chat clients with a small tool budget (Gemini, LibreChat) do better with fewer, so the surface can be narrowed at startup. A tool that is not published is also refused if a client calls it by name, and the refusal names the setting responsible.
|
|
560
561
|
|
|
561
562
|
| Toolset | Tools | Contents |
|
|
562
563
|
|---------|-------|----------|
|
|
@@ -570,7 +571,7 @@ All 83 tools are published by default. Chat clients with a small tool budget (Ge
|
|
|
570
571
|
| `query` | 1 | `actual_query_run` (SELECT only) |
|
|
571
572
|
| `admin` | 6 | Budget switching, import, export, bank sync and session management |
|
|
572
573
|
|
|
573
|
-
Each tool is in exactly one group. The `chat` preset cuts across them and publishes 12 tools: `actual_get_context`, `actual_query_run`, `actual_transactions_create`, `actual_transactions_update`, `actual_transactions_delete`, `actual_transactions_update_batch`, `actual_rules_create`, `actual_rules_create_batch`, `actual_budget_updates_batch`, `actual_budgets_transfer`, `actual_budgets_setAmount` and `actual_budgets_getMonth`. Measured on the `tools/list` payload, all
|
|
574
|
+
Each tool is in exactly one group. The `chat` preset cuts across them and publishes 12 tools: `actual_get_context`, `actual_query_run`, `actual_transactions_create`, `actual_transactions_update`, `actual_transactions_delete`, `actual_transactions_update_batch`, `actual_rules_create`, `actual_rules_create_batch`, `actual_budget_updates_batch`, `actual_budgets_transfer`, `actual_budgets_setAmount` and `actual_budgets_getMonth`. Measured on the `tools/list` payload, all 84 tools are 108,164 bytes and the `chat` preset is 25,949 bytes (about 6.5k tokens saved, a 76% cut).
|
|
574
575
|
|
|
575
576
|
`MCP_READ_ONLY=true` additionally hides and refuses every write-capable tool (47 of the 83, including `actual_bank_sync`, `actual_budgets_export`, `actual_budgets_switch` and `actual_session_close`), leaving 36. `actual_session_list` stays available under read-only. See [Security and Privacy](docs/SECURITY_AND_PRIVACY.md#tool-surface-mcp_toolsets-and-mcp_read_only).
|
|
576
577
|
|
|
@@ -648,7 +649,7 @@ stdio is the simplest way to connect Claude Desktop directly to Actual Budget. T
|
|
|
648
649
|
- No auth token. Process ownership is the security boundary.
|
|
649
650
|
- All logs go to stderr so they never corrupt the JSON-RPC framing on stdout
|
|
650
651
|
- The process exits when stdin closes (Claude Desktop shutting down)
|
|
651
|
-
- All
|
|
652
|
+
- All 84 tools are available, identical to HTTP mode
|
|
652
653
|
|
|
653
654
|
**Start manually to verify:**
|
|
654
655
|
|
|
@@ -751,7 +752,7 @@ See [AI Client Setup, OIDC](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-m
|
|
|
751
752
|
| Command | What It Tests | Requires Live Server |
|
|
752
753
|
|---------|---------------|---------------------|
|
|
753
754
|
| `npm run build` | TypeScript compilation | No |
|
|
754
|
-
| `npm run test:unit-js` |
|
|
755
|
+
| `npm run test:unit-js` | 84-tool smoke, schema validation, auth ACL | No |
|
|
755
756
|
| `npm run test:adapter` | Adapter, retry logic, concurrency | No |
|
|
756
757
|
| `npm run test:e2e` | MCP protocol compliance (Playwright) | No |
|
|
757
758
|
| `npm run test:e2e:docker:full` | Full stack integration | Yes (Docker) |
|
|
@@ -835,7 +836,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
|
|
|
835
836
|
|
|
836
837
|
---
|
|
837
838
|
|
|
838
|
-
**Version:** 0.22.
|
|
839
|
+
**Version:** 0.22.37 | **Tool Count:** 84 (verified LibreChat-compatible)
|
|
839
840
|
|
|
840
841
|
## Sponsor
|
|
841
842
|
|
|
@@ -16,6 +16,10 @@ let initializing = false;
|
|
|
16
16
|
let initializationError = null;
|
|
17
17
|
// Feature flag to enable connection pooling - can be disabled via environment variable
|
|
18
18
|
let useConnectionPool = process.env.USE_CONNECTION_POOL !== 'false';
|
|
19
|
+
/** #530: httpServer registers sessions with the pool only when the pool is in use. */
|
|
20
|
+
export function isConnectionPoolEnabled() {
|
|
21
|
+
return useConnectionPool;
|
|
22
|
+
}
|
|
19
23
|
export async function connectToActual() {
|
|
20
24
|
if (initialized)
|
|
21
25
|
return;
|
|
@@ -62,6 +62,7 @@ const IMPLEMENTED_TOOLS = [
|
|
|
62
62
|
'actual_transactions_aggregate',
|
|
63
63
|
'actual_transactions_create',
|
|
64
64
|
'actual_transactions_delete',
|
|
65
|
+
'actual_transactions_merge',
|
|
65
66
|
'actual_transactions_filter',
|
|
66
67
|
'actual_transactions_get',
|
|
67
68
|
'actual_transactions_import',
|
|
@@ -105,6 +106,7 @@ const API_TOOL_MAP = {
|
|
|
105
106
|
importTransactions: 'actual_transactions_import',
|
|
106
107
|
updateTransaction: 'actual_transactions_update',
|
|
107
108
|
deleteTransaction: 'actual_transactions_delete',
|
|
109
|
+
mergeTransactions: 'actual_transactions_merge',
|
|
108
110
|
getBudgetMonths: 'actual_budgets_getMonths',
|
|
109
111
|
getBudgetMonth: 'actual_budgets_getMonth',
|
|
110
112
|
setBudgetAmount: 'actual_budgets_setAmount',
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// src/auth/budget-acl-parse.ts
|
|
2
|
+
//
|
|
3
|
+
// #551: the pure parser for AUTH_BUDGET_ACL, shared by config.ts (startup
|
|
4
|
+
// validation) and budget-acl.ts (runtime classification).
|
|
5
|
+
//
|
|
6
|
+
// Before #551 a set-but-invalid value was parsed lazily in budget-acl.ts, logged
|
|
7
|
+
// once, and replaced by an EMPTY map, which getAllowedBudgets read as "no ACL
|
|
8
|
+
// configured": every authenticated principal was granted every budget. A typo in
|
|
9
|
+
// the one variable that restricts users to their budgets opened them all. Now a
|
|
10
|
+
// set value is validated at startup, and the runtime path denies on failure.
|
|
11
|
+
//
|
|
12
|
+
// WHY THIS MODULE HAS NO IMPORTS. config.ts validates the raw env value at
|
|
13
|
+
// startup by calling parseBudgetAcl. If the parser lived in budget-acl.ts, config
|
|
14
|
+
// would import a module that imports config (an ESM cycle leaving one of them
|
|
15
|
+
// half-initialised at evaluation time) and would also pull in the chain
|
|
16
|
+
// budget-acl.ts -> budget-acl-dynamic.ts -> lib/actual-adapter.ts -> @actual-app/api
|
|
17
|
+
// into every config load, stdio included. Keeping this file dependency-free (no
|
|
18
|
+
// logger, no config, no zod) is what makes validation at config-parse time
|
|
19
|
+
// possible at all. It throws and never logs.
|
|
20
|
+
//
|
|
21
|
+
// ERROR-TEXT CONTRACT. A thrown message may contain only the variable name, the
|
|
22
|
+
// problem class (not JSON, not an object, entry not an array of non-empty
|
|
23
|
+
// strings), the zero-based entry index in PARSED key order (JSON.parse puts
|
|
24
|
+
// integer-like keys first, so it is not always the order the operator wrote; the
|
|
25
|
+
// message says so), and the JSON type found.
|
|
26
|
+
// Never a key, a value, JSON.parse's own message (on Node 22 it echoes the input,
|
|
27
|
+
// which may hold email addresses), or an Error `cause` carrying any of those. The
|
|
28
|
+
// message is printed to stderr at startup and logged at runtime, so it must be
|
|
29
|
+
// safe to surface verbatim.
|
|
30
|
+
function jsonTypeOf(value) {
|
|
31
|
+
if (value === null)
|
|
32
|
+
return 'null';
|
|
33
|
+
if (Array.isArray(value))
|
|
34
|
+
return 'array';
|
|
35
|
+
return typeof value;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Parse the raw `AUTH_BUDGET_ACL` value.
|
|
39
|
+
*
|
|
40
|
+
* Accepts a JSON object whose every value is an array of non-empty strings
|
|
41
|
+
* (`["*"]` is the wildcard). `{}` is valid and means no restrictions; an empty
|
|
42
|
+
* array for a principal is valid and denies that principal. Throws a TypeError
|
|
43
|
+
* under the error-text contract above on anything else, including whitespace-only
|
|
44
|
+
* input, `null`, an array, a scalar, a non-array entry, and an array holding a
|
|
45
|
+
* non-string or an empty string. The caller decides what unset and '' mean.
|
|
46
|
+
*/
|
|
47
|
+
export function parseBudgetAcl(raw) {
|
|
48
|
+
let parsed;
|
|
49
|
+
try {
|
|
50
|
+
parsed = JSON.parse(raw);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
throw new TypeError('AUTH_BUDGET_ACL: not valid JSON');
|
|
54
|
+
}
|
|
55
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
56
|
+
throw new TypeError(`AUTH_BUDGET_ACL: must be a JSON object (got ${jsonTypeOf(parsed)})`);
|
|
57
|
+
}
|
|
58
|
+
// Null prototype on purpose (see BudgetAclMap). On such an object a plain
|
|
59
|
+
// assignment of the "__proto__" key stores an own property, so no setter can fire.
|
|
60
|
+
const map = Object.create(null);
|
|
61
|
+
// The index follows Object.entries order: integer-like keys (a numeric `sub`)
|
|
62
|
+
// come first and a duplicate key keeps only its last copy, so the message
|
|
63
|
+
// states the order rather than letting the operator count lines in the file.
|
|
64
|
+
const order = '; indexes are zero-based in parsed key order, integer-like keys first';
|
|
65
|
+
let index = 0;
|
|
66
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
67
|
+
if (!Array.isArray(value)) {
|
|
68
|
+
throw new TypeError(`AUTH_BUDGET_ACL: entry ${index} must be an array of non-empty strings (got ${jsonTypeOf(value)}${order})`);
|
|
69
|
+
}
|
|
70
|
+
for (const id of value) {
|
|
71
|
+
if (typeof id !== 'string') {
|
|
72
|
+
throw new TypeError(`AUTH_BUDGET_ACL: entry ${index} must be an array of non-empty strings (got an array with a ${jsonTypeOf(id)} element${order})`);
|
|
73
|
+
}
|
|
74
|
+
if (id.length === 0) {
|
|
75
|
+
throw new TypeError(`AUTH_BUDGET_ACL: entry ${index} must be an array of non-empty strings (got an array with an empty string${order})`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
map[key] = [...value];
|
|
79
|
+
index++;
|
|
80
|
+
}
|
|
81
|
+
return map;
|
|
82
|
+
}
|
|
@@ -9,13 +9,21 @@
|
|
|
9
9
|
// { "<principal>": ["<syncId1>", "<syncId2>"] }
|
|
10
10
|
//
|
|
11
11
|
// Principal keys:
|
|
12
|
-
// "alice@example.com"
|
|
13
|
-
// "some-sub-uuid"
|
|
14
|
-
// "group:admin"
|
|
12
|
+
// "alice@example.com" matched against the token email claim
|
|
13
|
+
// "some-sub-uuid" matched against the token sub claim
|
|
14
|
+
// "group:admin" matched against the token groups/roles array
|
|
15
15
|
//
|
|
16
16
|
// Value ["*"] grants access to all budgets (admin shorthand).
|
|
17
17
|
//
|
|
18
|
-
//
|
|
18
|
+
// Three states, classified once from the raw value and cached (#551):
|
|
19
|
+
// open (AUTH_BUDGET_ACL unset, '' or '{}'): every authenticated user is
|
|
20
|
+
// allowed ("*"), as documented.
|
|
21
|
+
// map (a valid non-empty object): the per-principal lookup below.
|
|
22
|
+
// deny (a value that does not parse): every principal gets [] and a 403.
|
|
23
|
+
// config.ts refuses startup on such a value, so this state is a
|
|
24
|
+
// defence in depth for any caller that reaches the parser another way.
|
|
25
|
+
// Before #551 a parse failure degraded to an EMPTY map, which read as
|
|
26
|
+
// "open": a typo in the ACL granted every budget to every user.
|
|
19
27
|
//
|
|
20
28
|
// Usage in httpServer.ts:
|
|
21
29
|
// app.use(httpPath, budgetAclMiddleware); // after bearerAuth()
|
|
@@ -24,31 +32,36 @@
|
|
|
24
32
|
// to filter results by permitted sync IDs (forward-looking multi-budget support).
|
|
25
33
|
import config from '../config.js';
|
|
26
34
|
import logger from '../logger.js';
|
|
35
|
+
import { parseBudgetAcl } from './budget-acl-parse.js';
|
|
27
36
|
import { resolveAllowedBudgetsFromActual, resolvePrincipalAsync } from './budget-acl-dynamic.js';
|
|
28
|
-
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (!config.AUTH_BUDGET_ACL) {
|
|
36
|
-
_aclMap = {};
|
|
37
|
-
return _aclMap;
|
|
38
|
-
}
|
|
37
|
+
let _aclState = null;
|
|
38
|
+
// Test seam (#551): when set, classify this raw value instead of config.AUTH_BUDGET_ACL.
|
|
39
|
+
let _rawOverride = null;
|
|
40
|
+
function classify(raw) {
|
|
41
|
+
if (raw === undefined || raw === '')
|
|
42
|
+
return { kind: 'open' };
|
|
43
|
+
let map;
|
|
39
44
|
try {
|
|
40
|
-
|
|
41
|
-
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
42
|
-
throw new TypeError('AUTH_BUDGET_ACL must be a JSON object');
|
|
43
|
-
}
|
|
44
|
-
_aclMap = parsed;
|
|
45
|
-
logger.info(`[ACL] Budget ACL loaded: ${Object.keys(_aclMap).length} principal(s)`);
|
|
45
|
+
map = parseBudgetAcl(raw);
|
|
46
46
|
}
|
|
47
47
|
catch (err) {
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
// Only the parser's message, which is safe by contract (it never names a key
|
|
49
|
+
// or a value); never the raw error object, whose cause or stack could.
|
|
50
|
+
const reason = err instanceof Error ? err.message : 'AUTH_BUDGET_ACL: unparseable';
|
|
51
|
+
logger.error(`[ACL] Invalid AUTH_BUDGET_ACL: access denied to every principal until it is fixed (${reason})`);
|
|
52
|
+
return { kind: 'deny' };
|
|
50
53
|
}
|
|
51
|
-
|
|
54
|
+
const count = Object.keys(map).length;
|
|
55
|
+
if (count === 0)
|
|
56
|
+
return { kind: 'open' };
|
|
57
|
+
logger.info(`[ACL] Budget ACL loaded: ${count} principal(s)`);
|
|
58
|
+
return { kind: 'map', map };
|
|
59
|
+
}
|
|
60
|
+
function getAclState() {
|
|
61
|
+
if (_aclState === null) {
|
|
62
|
+
_aclState = classify(_rawOverride !== null ? _rawOverride.raw : config.AUTH_BUDGET_ACL);
|
|
63
|
+
}
|
|
64
|
+
return _aclState;
|
|
52
65
|
}
|
|
53
66
|
// ---------------------------------------------------------------------------
|
|
54
67
|
// Public helpers
|
|
@@ -57,15 +70,23 @@ function getAclMap() {
|
|
|
57
70
|
* Returns the list of allowed budget sync-IDs for the currently authenticated
|
|
58
71
|
* request principal.
|
|
59
72
|
*
|
|
60
|
-
* - Returns `['*']`
|
|
73
|
+
* - Returns `['*']` in the open state (ACL unset, empty, or `{}`).
|
|
61
74
|
* - Returns `['*']` when any matched principal has wildcard access.
|
|
62
75
|
* - Returns `[]` when the user is authenticated but not in the ACL at all.
|
|
76
|
+
* - Returns `[]` for every principal in the deny state (the ACL value does not
|
|
77
|
+
* parse), never `['*']`.
|
|
78
|
+
*
|
|
79
|
+
* Lookups use Object.hasOwn so a principal named like an Object.prototype member
|
|
80
|
+
* (`constructor`, `toString`) is simply absent instead of matching the inherited
|
|
81
|
+
* function (which used to throw and surface as a 500).
|
|
63
82
|
*/
|
|
64
83
|
export function getAllowedBudgets(req) {
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
if (Object.keys(acl).length === 0)
|
|
84
|
+
const state = getAclState();
|
|
85
|
+
if (state.kind === 'open')
|
|
68
86
|
return ['*'];
|
|
87
|
+
if (state.kind === 'deny')
|
|
88
|
+
return [];
|
|
89
|
+
const acl = state.map;
|
|
69
90
|
const auth = req.auth;
|
|
70
91
|
if (!auth)
|
|
71
92
|
return [];
|
|
@@ -84,13 +105,15 @@ export function getAllowedBudgets(req) {
|
|
|
84
105
|
principals.push(`group:${g}`);
|
|
85
106
|
// Check for wildcard admin access first
|
|
86
107
|
for (const p of principals) {
|
|
87
|
-
if (acl[p]
|
|
108
|
+
if (Object.hasOwn(acl, p) && acl[p].includes('*'))
|
|
88
109
|
return ['*'];
|
|
89
110
|
}
|
|
90
111
|
// Collect all permitted sync-IDs
|
|
91
112
|
const permitted = new Set();
|
|
92
113
|
for (const p of principals) {
|
|
93
|
-
|
|
114
|
+
if (!Object.hasOwn(acl, p))
|
|
115
|
+
continue;
|
|
116
|
+
for (const id of acl[p])
|
|
94
117
|
permitted.add(id);
|
|
95
118
|
}
|
|
96
119
|
return [...permitted];
|
|
@@ -108,8 +131,8 @@ export function canAccessBudget(req, budgetSyncId) {
|
|
|
108
131
|
/**
|
|
109
132
|
* Express middleware that enforces the budget ACL when OIDC auth is active.
|
|
110
133
|
*
|
|
111
|
-
* - Falls through immediately when AUTH_PROVIDER !== 'oidc'
|
|
112
|
-
*
|
|
134
|
+
* - Falls through immediately when AUTH_PROVIDER !== 'oidc' (no-op, keeps
|
|
135
|
+
* backward compatibility). With OIDC and the ACL unset it attaches `['*']`.
|
|
113
136
|
* - Attaches `req.allowedBudgets` for downstream use (multi-budget tools).
|
|
114
137
|
* - Returns HTTP 403 if the authenticated user has no permitted budgets.
|
|
115
138
|
*
|
|
@@ -178,11 +201,23 @@ async function resolveDynamicAcl(req, res, next) {
|
|
|
178
201
|
res.status(403).json({ error: 'Forbidden: budget access could not be determined' });
|
|
179
202
|
}
|
|
180
203
|
}
|
|
181
|
-
/** Reset cached state (test helper). */
|
|
204
|
+
/** Reset cached state and the raw override (test helper). */
|
|
182
205
|
export function _resetAclForTests() {
|
|
183
|
-
|
|
206
|
+
_aclState = null;
|
|
207
|
+
_rawOverride = null;
|
|
184
208
|
}
|
|
185
|
-
/**
|
|
209
|
+
/**
|
|
210
|
+
* Directly set the ACL map (test helper; bypasses config/env). `{}` is the open
|
|
211
|
+
* state, exactly as an unset variable is.
|
|
212
|
+
*/
|
|
186
213
|
export function _setAclForTests(aclMap) {
|
|
187
|
-
|
|
214
|
+
_aclState = Object.keys(aclMap).length === 0 ? { kind: 'open' } : { kind: 'map', map: aclMap };
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* #551 test seam: classify `raw` as if it were the AUTH_BUDGET_ACL value, through
|
|
218
|
+
* the same parser and logging as the real path. Cleared by _resetAclForTests.
|
|
219
|
+
*/
|
|
220
|
+
export function _setRawAclForTests(raw) {
|
|
221
|
+
_rawOverride = { raw };
|
|
222
|
+
_aclState = null;
|
|
188
223
|
}
|
package/dist/src/config.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { parseIdentityMap } from './auth/identity-map.js';
|
|
3
|
+
import { parseBudgetAcl } from './auth/budget-acl-parse.js';
|
|
3
4
|
// #466: the byte count body-parser will ACTUALLY enforce for MCP_HTTP_BODY_LIMIT, or
|
|
4
5
|
// null when the value must be refused. Mirrors `bytes.parse` (what body-parser uses)
|
|
5
6
|
// without importing that transitive package: floor(number * unit), units b and kb
|
|
@@ -192,7 +193,34 @@ export const configSchema = z.object({
|
|
|
192
193
|
// Keys: email, sub, or "group:<name>". Values: array of sync IDs or ["*"] for all.
|
|
193
194
|
// Example: {"alice@example.com":["budget-1"],"group:admin":["*"]}
|
|
194
195
|
// Leave unset to allow all authenticated users to access all budgets.
|
|
195
|
-
|
|
196
|
+
// #551: a SET value must parse, whatever AUTH_PROVIDER and AUTH_BUDGET_ACL_SOURCE
|
|
197
|
+
// are. Unset and '' mean "no restrictions" and are not validated. Before this
|
|
198
|
+
// refine an invalid value was parsed lazily at runtime and degraded to an empty
|
|
199
|
+
// map, which the ACL read as "open": a typo granted every budget to every user.
|
|
200
|
+
// The parser is a zero-import leaf so this file can call it (see its header).
|
|
201
|
+
AUTH_BUDGET_ACL: z
|
|
202
|
+
.string()
|
|
203
|
+
.optional()
|
|
204
|
+
.superRefine((v, ctx) => {
|
|
205
|
+
if (v === undefined || v === '')
|
|
206
|
+
return;
|
|
207
|
+
try {
|
|
208
|
+
parseBudgetAcl(v);
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
// The parser's message is safe by contract (variable name, problem class,
|
|
212
|
+
// entry index and JSON type only; never a key, a value or JSON.parse's
|
|
213
|
+
// echo of the input). Its own "AUTH_BUDGET_ACL: " prefix is dropped here
|
|
214
|
+
// because the sentence already names the variable.
|
|
215
|
+
const reason = (err instanceof Error ? err.message : String(err)).replace(/^AUTH_BUDGET_ACL: /, '');
|
|
216
|
+
ctx.addIssue({
|
|
217
|
+
code: 'custom',
|
|
218
|
+
message: `AUTH_BUDGET_ACL is malformed (${reason}). ` +
|
|
219
|
+
'Expected a JSON object mapping each principal to an array of budget sync ids, for example ' +
|
|
220
|
+
'{"<email or sub>":["<sync id>"],"group:<name>":["*"]}. See docs/CONFIGURATION.md.',
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}),
|
|
196
224
|
// #338: where the budget ACL comes from.
|
|
197
225
|
// 'static' (default) = AUTH_BUDGET_ACL above, exactly as before.
|
|
198
226
|
// 'actual' = derived from the Actual server's own per-file access
|
|
@@ -36,6 +36,22 @@ class ActualConnectionPool {
|
|
|
36
36
|
// a lazily-cleaned table would leak transport objects for sessions a client
|
|
37
37
|
// abandons without reconnecting.
|
|
38
38
|
evictionCallbacks = [];
|
|
39
|
+
// #530: every HTTP session the transport layer registered, with the time it was last
|
|
40
|
+
// served. An entry in `connections` is not the only thing that must expire: an
|
|
41
|
+
// infra-error drop (or `switchBudget`'s slow path) removes the pool entry WITHOUT
|
|
42
|
+
// evicting the transport, and before #530 a client that then went away left its
|
|
43
|
+
// transport, owner record and init promise behind forever, because nothing timed an
|
|
44
|
+
// entry-less session. The sweep reaps a registered id that has no entry and was not
|
|
45
|
+
// served for one idle window ("orphaned"). `touch()` stamps it, so a served request
|
|
46
|
+
// keeps the session alive whether or not the entry is back.
|
|
47
|
+
registered = new Map();
|
|
48
|
+
// #530 (review M1): requests currently being served per session. The orphan sweep skips a
|
|
49
|
+
// session with a request in flight: an entry-less session running a long operation (an
|
|
50
|
+
// import past the idle window, or a request queued behind another session's drain) has
|
|
51
|
+
// stamped its registration only at the START of the request, so without this the sweep
|
|
52
|
+
// would close its transport mid-operation. Entries have the #392 lock re-check and the
|
|
53
|
+
// #378 in-drain touch for the same hazard; registrations have neither.
|
|
54
|
+
inFlight = new Map();
|
|
39
55
|
constructor() {
|
|
40
56
|
// Read from environment variable or default to 15
|
|
41
57
|
// @actual-app/api is a singleton, so concurrent sessions cause conflicts
|
|
@@ -116,7 +132,24 @@ class ActualConnectionPool {
|
|
|
116
132
|
* sweep so the two can never disagree about where the boundary is (#167).
|
|
117
133
|
*/
|
|
118
134
|
isExpired(conn) {
|
|
119
|
-
|
|
135
|
+
// #530: a registered session's clock is the later of the entry's lastActivity and its
|
|
136
|
+
// last served request, so a recycled entry cannot make a session look idle.
|
|
137
|
+
return this.isPastIdle(Math.max(conn.lastActivity, this.registered.get(conn.sessionId) ?? 0));
|
|
138
|
+
}
|
|
139
|
+
/** The one boundary for entries and registrations alike (#167, #530). */
|
|
140
|
+
isPastIdle(lastActivity) {
|
|
141
|
+
return (Date.now() - lastActivity) > this.IDLE_TIMEOUT;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* #530: the transport layer registers each HTTP session when it registers the transport,
|
|
145
|
+
* so the pool can time it even while it has no entry. Idempotent; stamps "now".
|
|
146
|
+
*/
|
|
147
|
+
registerSession(sessionId) {
|
|
148
|
+
this.registered.set(sessionId, Date.now());
|
|
149
|
+
}
|
|
150
|
+
/** #530: drop a registration (idempotent). Every eviction path calls this itself. */
|
|
151
|
+
forgetSession(sessionId) {
|
|
152
|
+
this.registered.delete(sessionId);
|
|
120
153
|
}
|
|
121
154
|
/**
|
|
122
155
|
* Refresh a session's activity timestamp. Called by the transport layer on
|
|
@@ -124,28 +157,61 @@ class ActualConnectionPool {
|
|
|
124
157
|
* this, the pool only stamped lastActivity at init/switch, so an actively
|
|
125
158
|
* used session's pool clock never advanced; httpServer kept a parallel
|
|
126
159
|
* activity map to compensate, which is the drift this consolidation removes.
|
|
127
|
-
* No-op for unknown sessions (does NOT create an entry).
|
|
160
|
+
* No-op for unknown sessions (does NOT create an entry). #530: also stamps the session's
|
|
161
|
+
* registration when it has one, so a session whose entry was dropped stays alive while
|
|
162
|
+
* it is being served (the #378 in-drain touch feeds the same clock). Never registers.
|
|
128
163
|
*/
|
|
129
164
|
touch(sessionId) {
|
|
165
|
+
const now = Date.now();
|
|
130
166
|
const conn = this.connections.get(sessionId);
|
|
131
167
|
if (conn) {
|
|
132
|
-
conn.lastActivity =
|
|
168
|
+
conn.lastActivity = now;
|
|
169
|
+
}
|
|
170
|
+
if (this.registered.has(sessionId)) {
|
|
171
|
+
this.registered.set(sessionId, now);
|
|
133
172
|
}
|
|
134
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* #530: mark a request as in flight for the session (and touch it). While the count is
|
|
176
|
+
* above zero the orphan sweep leaves the session alone. httpServer calls this on POST only:
|
|
177
|
+
* an open GET event stream is idle by nature and must not pin an orphan.
|
|
178
|
+
*/
|
|
179
|
+
beginRequest(sessionId) {
|
|
180
|
+
this.touch(sessionId);
|
|
181
|
+
this.inFlight.set(sessionId, (this.inFlight.get(sessionId) ?? 0) + 1);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* #530: the request has ended (response finished or the client went away). Drops the
|
|
185
|
+
* in-flight count and touches, so the idle window starts from the END of a long request,
|
|
186
|
+
* not its start. Idempotent for an unknown or already-evicted session.
|
|
187
|
+
*/
|
|
188
|
+
endRequest(sessionId) {
|
|
189
|
+
const n = this.inFlight.get(sessionId);
|
|
190
|
+
if (n !== undefined) {
|
|
191
|
+
if (n <= 1)
|
|
192
|
+
this.inFlight.delete(sessionId);
|
|
193
|
+
else
|
|
194
|
+
this.inFlight.set(sessionId, n - 1);
|
|
195
|
+
}
|
|
196
|
+
this.touch(sessionId);
|
|
197
|
+
}
|
|
135
198
|
/**
|
|
136
199
|
* Register a listener invoked when the pool removes a session (idle sweep or
|
|
137
200
|
* explicit close). httpServer registers one that closes the transport and
|
|
138
201
|
* drops its table entries, keeping both tables consistent (#167). Listeners
|
|
139
202
|
* must not throw; any error is logged and swallowed so one bad listener
|
|
140
|
-
* cannot abort the removal of others.
|
|
203
|
+
* cannot abort the removal of others. #530: the listener also receives the reason
|
|
204
|
+
* (`evicted` for the #167 paths, `orphaned` for the entry-less sweep).
|
|
141
205
|
*/
|
|
142
206
|
onSessionEvicted(cb) {
|
|
143
207
|
this.evictionCallbacks.push(cb);
|
|
144
208
|
}
|
|
145
|
-
fireEviction(sessionId) {
|
|
209
|
+
fireEviction(sessionId, reason = 'evicted') {
|
|
210
|
+
this.registered.delete(sessionId); // #530: whatever the path, the session is gone
|
|
211
|
+
this.inFlight.delete(sessionId); // #530: a late endRequest finds nothing to decrement
|
|
146
212
|
for (const cb of this.evictionCallbacks) {
|
|
147
213
|
try {
|
|
148
|
-
cb(sessionId);
|
|
214
|
+
cb(sessionId, reason);
|
|
149
215
|
}
|
|
150
216
|
catch (err) {
|
|
151
217
|
logger.error(`[ConnectionPool] Eviction listener threw for session ${sessionId} (ignoring):`, err);
|
|
@@ -632,6 +698,23 @@ class ActualConnectionPool {
|
|
|
632
698
|
await this.shutdownConnection(sessionId, { evict: true, onlyIfExpired: true });
|
|
633
699
|
}
|
|
634
700
|
}
|
|
701
|
+
// #530: registered sessions with no entry. Walked AFTER the entry evictions (each of
|
|
702
|
+
// which already dropped its registration) and synchronously: there is no api call to
|
|
703
|
+
// make, only the transport layer's tables to clear, so the lock is not involved and a
|
|
704
|
+
// request served in the meantime has already stamped the registration. A session with
|
|
705
|
+
// a request still in flight is never an orphan, however long that request runs.
|
|
706
|
+
const orphans = [];
|
|
707
|
+
for (const [sessionId, lastServed] of this.registered.entries()) {
|
|
708
|
+
if (!this.connections.has(sessionId) && !this.inFlight.has(sessionId) && this.isPastIdle(lastServed)) {
|
|
709
|
+
orphans.push(sessionId);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
if (orphans.length > 0) {
|
|
713
|
+
logger.info(`[ConnectionPool] Reaping ${orphans.length} orphaned sessions (no pool entry, idle for one window)`);
|
|
714
|
+
for (const sessionId of orphans) {
|
|
715
|
+
this.fireEviction(sessionId, 'orphaned');
|
|
716
|
+
}
|
|
717
|
+
}
|
|
635
718
|
}
|
|
636
719
|
/**
|
|
637
720
|
* Shutdown all connections and stop cleanup timer
|
|
@@ -680,6 +763,8 @@ class ActualConnectionPool {
|
|
|
680
763
|
// otherwise fire once per session at the exact moment the code chose not to take it.
|
|
681
764
|
await teardown(true).catch(() => { });
|
|
682
765
|
});
|
|
766
|
+
this.registered.clear(); // #530: the transport layer is going down with the pool
|
|
767
|
+
this.inFlight.clear();
|
|
683
768
|
logger.info('[ConnectionPool] All connections shut down');
|
|
684
769
|
}
|
|
685
770
|
/**
|