actual-mcp-server 0.11.1 → 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/package.json +1 -1
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` +
|