actual-mcp-server 0.25.2 → 0.26.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 +5 -5
- package/dist/src/actualToolsManager.js +1 -0
- package/dist/src/config.js +4 -0
- package/dist/src/index.js +29 -53
- package/dist/src/lib/actual-adapter.js +122 -75
- package/dist/src/lib/apiLogin.js +7 -1
- package/dist/src/lib/authPosture.js +17 -0
- package/dist/src/lib/budget-registry.js +39 -1
- package/dist/src/lib/constants.js +8 -8
- package/dist/src/lib/mcp-version.js +6 -0
- package/dist/src/lib/processHandlers.js +183 -0
- package/dist/src/lib/processHandlersInstall.js +25 -0
- package/dist/src/lib/rejection-allowlist.js +4 -3
- package/dist/src/lib/schemas/common.js +1 -1
- package/dist/src/lib/tool-annotations.js +5 -4
- package/dist/src/lib/toolsets.js +2 -1
- package/dist/src/server/httpServer.js +7 -4
- package/dist/src/server/stdioServer.js +5 -2
- package/dist/src/tools/bank_sync.js +22 -9
- package/dist/src/tools/budgets_list_available.js +1 -1
- package/dist/src/tools/index.js +1 -0
- package/dist/src/tools/schedules_dates.js +35 -0
- package/dist/src/tools/schedules_update.js +6 -4
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ A [Model Context Protocol](https://modelcontextprotocol.io/) server that lets an
|
|
|
15
15
|
|
|
16
16
|
**What you can ask it:** "How much did I spend on groceries last month, by payee?", "Add a $47.50 grocery transaction at Whole Foods to my checking account.", "Move $200 from Dining Out to Emergency Fund for this month."
|
|
17
17
|
|
|
18
|
-
**
|
|
18
|
+
**85 tools** across accounts, transactions, budgets, rules, schedules and more, over stdio or HTTP, with Bearer token or OIDC auth, per-user budget access, up to 15 concurrent sessions, and automatic retry of transient Actual errors (3 attempts, exponential backoff; deletes and merges are never retried).
|
|
19
19
|
|
|
20
20
|
## Pick your path
|
|
21
21
|
|
|
@@ -98,7 +98,7 @@ Every tool, with its description and caveats, is in the [tools reference](docs/r
|
|
|
98
98
|
|
|
99
99
|
### Reducing the tool surface
|
|
100
100
|
|
|
101
|
-
All
|
|
101
|
+
All 85 tools are published by default. Chat clients with a small tool budget (Gemini, LibreChat) do better with fewer. A tool that is not published is also refused if a client calls it by name.
|
|
102
102
|
|
|
103
103
|
<!-- BEGIN GENERATED: tools-summary -->
|
|
104
104
|
<!-- Generated from dist/src/lib/toolsets.js by `npm run docs:gen`. Do not edit. -->
|
|
@@ -109,14 +109,14 @@ All 84 tools are published by default. Chat clients with a small tool budget (Ge
|
|
|
109
109
|
| `analysis` | 7 | Summaries, aggregates and balances |
|
|
110
110
|
| `budget` | 10 | Budget months, amounts, carryover, holds, transfers, batch updates and notes |
|
|
111
111
|
| `rules` | 7 | Rules and payee rules, including `actual_rules_create_batch` |
|
|
112
|
-
| `schedules` |
|
|
112
|
+
| `schedules` | 5 | Scheduled transactions |
|
|
113
113
|
| `structure` | 21 | Accounts, account groups, categories, category groups, payees and tags |
|
|
114
114
|
| `query` | 1 | `actual_query_run` (SELECT only) |
|
|
115
115
|
| `admin` | 6 | Budget switching, import, export, bank sync and session management |
|
|
116
116
|
|
|
117
117
|
The `chat` preset publishes 12 tools: `actual_get_context`, `actual_query_run`, `actual_transactions_update_batch`, `actual_budget_updates_batch`, `actual_rules_create_batch`, `actual_transactions_create`, `actual_transactions_update`, `actual_transactions_delete`, `actual_rules_create`, `actual_budgets_transfer`, `actual_budgets_setAmount` and `actual_budgets_getMonth`.
|
|
118
118
|
|
|
119
|
-
`MCP_READ_ONLY=true` hides and refuses 48 write-capable tools and leaves
|
|
119
|
+
`MCP_READ_ONLY=true` hides and refuses 48 write-capable tools and leaves 37 published.
|
|
120
120
|
<!-- END GENERATED: tools-summary -->
|
|
121
121
|
|
|
122
122
|
Example: a chat assistant with two extra tools.
|
|
@@ -169,4 +169,4 @@ MIT, see [LICENSE](LICENSE). Built on [Actual Budget](https://actualbudget.org/)
|
|
|
169
169
|
|
|
170
170
|
---
|
|
171
171
|
|
|
172
|
-
**Version:** 0.
|
|
172
|
+
**Version:** 0.26.1 | **Tool Count:** 85 (verified LibreChat-compatible)
|
package/dist/src/config.js
CHANGED
|
@@ -89,6 +89,10 @@ export const configSchema = z.object({
|
|
|
89
89
|
// password is set (e.g. an isolated Docker network where the hop is trusted).
|
|
90
90
|
// Off by default so a plaintext upstream + encryption password is refused.
|
|
91
91
|
ALLOW_INSECURE_UPSTREAM: z.string().optional().transform(val => val === 'true'),
|
|
92
|
+
// #620: upstream @actual-app/api logs at its own info/debug level (sync chatter, and on every
|
|
93
|
+
// import or bank sync a dump of each reconciled transaction) unless init is told otherwise.
|
|
94
|
+
// Off by default; `=== 'true'` fails safe, since turning it on is what carries the risk.
|
|
95
|
+
ACTUAL_API_VERBOSE: z.string().optional().transform(val => val === 'true'),
|
|
92
96
|
MCP_BRIDGE_DATA_DIR: z.string().default('./actual-data'),
|
|
93
97
|
// #332: where actual_budgets_export writes budget zips. A budget export is
|
|
94
98
|
// megabytes of binary, so the tool returns a PATH plus metadata rather than
|
package/dist/src/index.js
CHANGED
|
@@ -5,12 +5,16 @@
|
|
|
5
5
|
// guard cannot live in bin/ alone. It is a side-effect import: the module's top-level
|
|
6
6
|
// `enforceNodeVersion()` fires here, exactly once.
|
|
7
7
|
import './lib/node-version-guard.js';
|
|
8
|
+
// #622: MUST be the second import. Registers the unhandledRejection and uncaughtException
|
|
9
|
+
// handlers before any other module evaluates. They write gated records only (no reason,
|
|
10
|
+
// message or stack) and get the logger later through attachProcessLogger below.
|
|
11
|
+
import { attachProcessLogger } from './lib/processHandlersInstall.js';
|
|
8
12
|
import { getMcpServerVersion } from './lib/mcp-version.js';
|
|
9
13
|
import path from 'node:path';
|
|
10
14
|
import { z } from 'zod';
|
|
11
|
-
import { isKnownBenignRejection } from './lib/rejection-allowlist.js';
|
|
12
15
|
import { buildServerInstructions } from './lib/serverInstructions.js';
|
|
13
16
|
import { format } from 'node:util';
|
|
17
|
+
import { writeSync } from 'node:fs';
|
|
14
18
|
/*
|
|
15
19
|
* #277 / #541: this module's version comes from `getMcpServerVersion()` (src/lib/mcp-version.ts),
|
|
16
20
|
* which `--version`, `--help`, the startup banner, the MCP initialize `serverInfo.version` and
|
|
@@ -22,54 +26,6 @@ import { format } from 'node:util';
|
|
|
22
26
|
* ignored `VERSION`, so a Docker image built as `X-dev-abc` printed plain `X` from
|
|
23
27
|
* `--version` while clients saw `X-dev-abc`. One reader means one string.
|
|
24
28
|
*/
|
|
25
|
-
// Add global error handlers
|
|
26
|
-
process.on('unhandledRejection', (reason, promise) => {
|
|
27
|
-
console.error('=== UNHANDLED REJECTION ===');
|
|
28
|
-
console.error('Promise:', promise);
|
|
29
|
-
console.error('Reason:', reason);
|
|
30
|
-
if (reason instanceof Error) {
|
|
31
|
-
console.error('Stack:', reason.stack);
|
|
32
|
-
}
|
|
33
|
-
console.error('===========================');
|
|
34
|
-
// #275 backstop. The guard above catches both entry points, but a consumer that
|
|
35
|
-
// imports this package as a library reaches neither. Import attributes are the one
|
|
36
|
-
// construct here that fails on an under-floor Node, and it fails asynchronously,
|
|
37
|
-
// so name the real cause rather than letting the reader chase a packaging bug.
|
|
38
|
-
if (reason?.code === 'ERR_IMPORT_ASSERTION_TYPE_MISSING') {
|
|
39
|
-
console.error(`⚠️ This almost certainly means Node ${process.version} is too old.`);
|
|
40
|
-
console.error('⚠️ actual-mcp-server requires the Node version in package.json "engines".');
|
|
41
|
-
console.error('⚠️ Import attributes (`with { type: "json" }`) need Node 22 or newer.');
|
|
42
|
-
}
|
|
43
|
-
if (isKnownBenignRejection(reason)) {
|
|
44
|
-
console.error('⚠️ Known Actual API domain error escaped to unhandledRejection:');
|
|
45
|
-
console.error('⚠️ ' + String(reason));
|
|
46
|
-
console.error('⚠️ Server will continue running. The caller received an error response.');
|
|
47
|
-
return;
|
|
48
|
-
}
|
|
49
|
-
process.exit(1);
|
|
50
|
-
});
|
|
51
|
-
process.on('uncaughtException', (error) => {
|
|
52
|
-
const errMsg = String(error?.message || error);
|
|
53
|
-
const errType = error?.type;
|
|
54
|
-
// Bank sync errors from GoCardless/SimpleFIN can surface as uncaughtException
|
|
55
|
-
// from within the @actual-app/api SDK when the provider API returns an error
|
|
56
|
-
// asynchronously (e.g. RATE_LIMIT_EXCEEDED from GoCardless/Nordigen).
|
|
57
|
-
// These are non-fatal — the server should survive and continue serving requests.
|
|
58
|
-
if (errType === 'BankSyncError' ||
|
|
59
|
-
errMsg.includes('BankSyncError') ||
|
|
60
|
-
errMsg.includes('NORDIGEN_ERROR') ||
|
|
61
|
-
errMsg.includes('RATE_LIMIT_EXCEEDED') ||
|
|
62
|
-
errMsg.includes('Rate limit exceeded') ||
|
|
63
|
-
errMsg.includes('Failed syncing account') ||
|
|
64
|
-
errMsg.includes('GoCardless') ||
|
|
65
|
-
errMsg.includes('SimpleFIN')) {
|
|
66
|
-
console.error('⚠️ [BANK SYNC] Non-fatal bank sync error surfaced as uncaughtException (server continues):');
|
|
67
|
-
console.error('⚠️ ' + errMsg);
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
console.error('Uncaught Exception:', error);
|
|
71
|
-
process.exit(1);
|
|
72
|
-
});
|
|
73
29
|
// Minimal early help handling before any side-effectful modules (prevents dotenv from running on --help)
|
|
74
30
|
const argsEarly = process.argv.slice(2);
|
|
75
31
|
// Set MCP_STDIO_MODE before the async IIFE so it is in place when src/logger.ts is first
|
|
@@ -191,7 +147,7 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
|
|
|
191
147
|
import('./actualConnection.js'),
|
|
192
148
|
import('./lib/ActualMCPConnection.js'),
|
|
193
149
|
]);
|
|
194
|
-
const [{ startHttpServer }, { startStdioServer }, { enforceHttpAuthPosture, findWeakTokenVars }, loggerModule, utilsModule, actualToolsManagerModule,] = await Promise.all([
|
|
150
|
+
const [{ startHttpServer }, { startStdioServer }, { enforceHttpAuthPosture, findWeakTokenVars, describeAuthMode }, loggerModule, utilsModule, actualToolsManagerModule,] = await Promise.all([
|
|
195
151
|
import('./server/httpServer.js'),
|
|
196
152
|
import('./server/stdioServer.js'),
|
|
197
153
|
import('./lib/authPosture.js'),
|
|
@@ -200,6 +156,9 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
|
|
|
200
156
|
import('./actualToolsManager.js'),
|
|
201
157
|
]);
|
|
202
158
|
const logger = loggerModule.default;
|
|
159
|
+
// #622: the process handlers log survivable events through the logger from here on.
|
|
160
|
+
const { createModuleLogger } = await import('./lib/loggerFactory.js');
|
|
161
|
+
attachProcessLogger(createModuleLogger('PROCESS'));
|
|
203
162
|
const { getLocalIp } = utilsModule;
|
|
204
163
|
const actualToolsManager = actualToolsManagerModule.default;
|
|
205
164
|
// #541: the build-time VERSION, or the root package version plus a dev suffix on a
|
|
@@ -213,7 +172,7 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
|
|
|
213
172
|
const useStdio = args.includes('--stdio');
|
|
214
173
|
const useTestActualConnection = args.includes('--test-actual-connection');
|
|
215
174
|
const useTestMcpClient = args.includes('--test-mcp-client');
|
|
216
|
-
const SERVER_DESCRIPTION = '
|
|
175
|
+
const SERVER_DESCRIPTION = 'Actual Budget over the Model Context Protocol';
|
|
217
176
|
async function main() {
|
|
218
177
|
// Mutual exclusion — stdio and http are incompatible transports
|
|
219
178
|
if (useHttp && useStdio) {
|
|
@@ -359,7 +318,16 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
|
|
|
359
318
|
logger.info('---------');
|
|
360
319
|
logger.info('🟡 MCP SERVER INFO');
|
|
361
320
|
logger.info(`• Server Description: ${SERVER_DESCRIPTION}`);
|
|
362
|
-
|
|
321
|
+
// #626: the real mode, computed with the same blank-means-off rules as the
|
|
322
|
+
// posture check. MCP_SSE_AUTHORIZATION stays untrimmed: a whitespace-only
|
|
323
|
+
// value is still a live credential there.
|
|
324
|
+
const { staticTokensConfigured } = await import('./auth/static-tokens.js');
|
|
325
|
+
const authMode = describeAuthMode({
|
|
326
|
+
oidcEnabled: process.env.AUTH_PROVIDER === 'oidc',
|
|
327
|
+
hasStaticTokens: staticTokensConfigured(process.env.MCP_STATIC_TOKENS),
|
|
328
|
+
hasSseAuthorization: !!process.env.MCP_SSE_AUTHORIZATION,
|
|
329
|
+
});
|
|
330
|
+
logger.info(`• Auth mode: ${authMode}`);
|
|
363
331
|
logger.info(`• Capabilities: ${Object.keys(capabilities).join(', ')}`);
|
|
364
332
|
logger.info(`• Tools: ${implementedTools.join(', ') || 'none'}`);
|
|
365
333
|
logger.info(`• Server Instructions: ${serverInstructionsText}`);
|
|
@@ -390,4 +358,12 @@ Docs & source: https://github.com/agigante80/actual-mcp-server`);
|
|
|
390
358
|
logger.error(`Failed to start Actual MCP bridge: ${err?.message || String(err)}`);
|
|
391
359
|
process.exit(1);
|
|
392
360
|
});
|
|
393
|
-
})()
|
|
361
|
+
})().catch((err) => {
|
|
362
|
+
// #622: the prelude above (dotenv, the dynamic imports, the logger) runs before main(), so
|
|
363
|
+
// its failures never reach main().catch. Without this catch they escape as an
|
|
364
|
+
// unhandledRejection, whose gated record drops the message: a missing package or a broken
|
|
365
|
+
// build would name no module. Same residual as main().catch: startup text only. The logger
|
|
366
|
+
// may not exist yet, so this writes synchronously to stderr, never stdout.
|
|
367
|
+
writeSync(2, `Failed to start Actual MCP bridge: ${err?.message || String(err)}\n`);
|
|
368
|
+
process.exit(1);
|
|
369
|
+
});
|
|
@@ -5,7 +5,7 @@ import api from '@actual-app/api';
|
|
|
5
5
|
// cannot expose its named exports via static import syntax. At runtime the default import
|
|
6
6
|
// IS module.exports, so all methods are accessible as properties.
|
|
7
7
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
8
|
-
const { addTransactions: rawAddTransactions, getAccounts: rawGetAccounts, importTransactions: rawImportTransactions, getTransactions: rawGetTransactions, getCategories: rawGetCategories, createCategory: rawCreateCategory, getPayees: rawGetPayees, getCommonPayees: rawGetCommonPayees, createPayee: rawCreatePayee, getBudgetMonths: rawGetBudgetMonths, getBudgetMonth: rawGetBudgetMonth, setBudgetAmount: rawSetBudgetAmount, createAccount: rawCreateAccount, updateAccount: rawUpdateAccount, getAccountBalance: rawGetAccountBalance, updateTransaction: rawUpdateTransaction, deleteTransaction: rawDeleteTransaction, updateCategory: rawUpdateCategory, deleteCategory: rawDeleteCategory, updatePayee: rawUpdatePayee, deletePayee: rawDeletePayee, deleteAccount: rawDeleteAccount, getRules: rawGetRules, createRule: rawCreateRule, updateRule: rawUpdateRule, deleteRule: rawDeleteRule, setBudgetCarryover: rawSetBudgetCarryover, closeAccount: rawCloseAccount, reopenAccount: rawReopenAccount, getCategoryGroups: rawGetCategoryGroups, createCategoryGroup: rawCreateCategoryGroup, updateCategoryGroup: rawUpdateCategoryGroup, deleteCategoryGroup: rawDeleteCategoryGroup, mergePayees: rawMergePayees, mergeTransactions: rawMergeTransactions, getPayeeRules: rawGetPayeeRules, holdBudgetForNextMonth: rawHoldBudgetForNextMonth, resetBudgetHold: rawResetBudgetHold, runQuery: rawRunQuery, runBankSync: rawRunBankSync, getBudgets: rawGetBudgets, getIDByName: rawGetIDByName, getServerVersion: rawGetServerVersion, getSchedules: rawGetSchedules, createSchedule: rawCreateSchedule, updateSchedule: rawUpdateSchedule, deleteSchedule: rawDeleteSchedule, getTags: rawGetTags, createTag: rawCreateTag, updateTag: rawUpdateTag, deleteTag: rawDeleteTag, getNote: rawGetNote, updateNote: rawUpdateNote, exportBudget: rawExportBudget, importBudget: rawImportBudget, getPreferences: rawGetPreferences, getAccountGroups: rawGetAccountGroups, createAccountGroup: rawCreateAccountGroup, updateAccountGroup: rawUpdateAccountGroup, deleteAccountGroup: rawDeleteAccountGroup,
|
|
8
|
+
const { addTransactions: rawAddTransactions, getAccounts: rawGetAccounts, importTransactions: rawImportTransactions, getTransactions: rawGetTransactions, getCategories: rawGetCategories, createCategory: rawCreateCategory, getPayees: rawGetPayees, getCommonPayees: rawGetCommonPayees, createPayee: rawCreatePayee, getBudgetMonths: rawGetBudgetMonths, getBudgetMonth: rawGetBudgetMonth, setBudgetAmount: rawSetBudgetAmount, createAccount: rawCreateAccount, updateAccount: rawUpdateAccount, getAccountBalance: rawGetAccountBalance, updateTransaction: rawUpdateTransaction, deleteTransaction: rawDeleteTransaction, updateCategory: rawUpdateCategory, deleteCategory: rawDeleteCategory, updatePayee: rawUpdatePayee, deletePayee: rawDeletePayee, deleteAccount: rawDeleteAccount, getRules: rawGetRules, createRule: rawCreateRule, updateRule: rawUpdateRule, deleteRule: rawDeleteRule, setBudgetCarryover: rawSetBudgetCarryover, closeAccount: rawCloseAccount, reopenAccount: rawReopenAccount, getCategoryGroups: rawGetCategoryGroups, createCategoryGroup: rawCreateCategoryGroup, updateCategoryGroup: rawUpdateCategoryGroup, deleteCategoryGroup: rawDeleteCategoryGroup, mergePayees: rawMergePayees, mergeTransactions: rawMergeTransactions, getPayeeRules: rawGetPayeeRules, holdBudgetForNextMonth: rawHoldBudgetForNextMonth, resetBudgetHold: rawResetBudgetHold, runQuery: rawRunQuery, runBankSync: rawRunBankSync, getBudgets: rawGetBudgets, getIDByName: rawGetIDByName, getServerVersion: rawGetServerVersion, getSchedules: rawGetSchedules, getScheduleDates: rawGetScheduleDates, createSchedule: rawCreateSchedule, updateSchedule: rawUpdateSchedule, deleteSchedule: rawDeleteSchedule, getTags: rawGetTags, createTag: rawCreateTag, updateTag: rawUpdateTag, deleteTag: rawDeleteTag, getNote: rawGetNote, updateNote: rawUpdateNote, exportBudget: rawExportBudget, importBudget: rawImportBudget, getPreferences: rawGetPreferences, getAccountGroups: rawGetAccountGroups, createAccountGroup: rawCreateAccountGroup, updateAccountGroup: rawUpdateAccountGroup, deleteAccountGroup: rawDeleteAccountGroup,
|
|
9
9
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
10
10
|
} = api;
|
|
11
11
|
import { EventEmitter } from 'events';
|
|
@@ -13,13 +13,13 @@ import observability from '../observability.js';
|
|
|
13
13
|
import { retry, isRetryableError, isRateLimitError } from './retry.js';
|
|
14
14
|
import { withOpTimeout } from './opTimeout.js';
|
|
15
15
|
import { markInitFailure } from './init-failure.js';
|
|
16
|
-
import { NotFoundRefusal, OutOfRangeRefusal, constraintErrorMsg, errorLogFields } from './errors.js';
|
|
16
|
+
import { NotFoundRefusal, OutOfRangeRefusal, constraintErrorMsg, errorLogFields, isPreflightRefusal } from './errors.js';
|
|
17
17
|
import { amountOpMismatch } from './schemas/schedule-amount.js';
|
|
18
18
|
import { findMatchingRule } from './rule-matching.js';
|
|
19
19
|
import logger from '../logger.js';
|
|
20
20
|
import { checkServerVersionOnce } from './server-version-guard.js';
|
|
21
21
|
import config from '../config.js';
|
|
22
|
-
import { parseBudgetRegistry } from './budget-registry.js';
|
|
22
|
+
import { formatBudgetRegistrySummary, logSafeOrigin, parseBudgetRegistry } from './budget-registry.js';
|
|
23
23
|
import { getPreferredBudgetSyncId, setPreferredBudgetSyncId, pickAllowedPreferredBudget } from './budget-preference-store.js';
|
|
24
24
|
import { requestContext } from './requestContext.js';
|
|
25
25
|
import { connectionPool } from './ActualConnectionPool.js';
|
|
@@ -38,8 +38,8 @@ const budgetRegistry = parseBudgetRegistry(process.env, {
|
|
|
38
38
|
encryptionPassword: config.ACTUAL_BUDGET_PASSWORD,
|
|
39
39
|
});
|
|
40
40
|
setLoginsSpanServers(new Set([...budgetRegistry.values()].map((b) => JSON.stringify([b.serverUrl, b.password || '']))).size > 1);
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
// #621: counts and schemes only; budget names and server hosts stay out of the startup log.
|
|
42
|
+
logger.info(`[ADAPTER] ${formatBudgetRegistrySummary(budgetRegistry)}`);
|
|
43
43
|
/**
|
|
44
44
|
* Per-session active budget. Map from sessionId to lowercased budget key
|
|
45
45
|
* (matches the keys in budgetRegistry).
|
|
@@ -276,7 +276,7 @@ function loginOf(budget) {
|
|
|
276
276
|
async function loadBudgetOnItsServer(budget) {
|
|
277
277
|
const login = loginOf(budget);
|
|
278
278
|
if (!isApiLoggedInTo(login)) {
|
|
279
|
-
logger.info(`[ADAPTER] logging the api in to ${budget.serverUrl} before loading "${budget.name}"`);
|
|
279
|
+
logger.info(`[ADAPTER] logging the api in to ${logSafeOrigin(budget.serverUrl)} before loading "${budget.name}"`);
|
|
280
280
|
await withAuthRetry(() => initApiLoggedIn(config.MCP_BRIDGE_DATA_DIR, login, 'init'));
|
|
281
281
|
}
|
|
282
282
|
await loadBudgetTracked(budget.syncId, budget.encryptionPassword);
|
|
@@ -828,7 +828,7 @@ async function initActualApiForOperation() {
|
|
|
828
828
|
try {
|
|
829
829
|
const budget = getActiveBudgetConfig();
|
|
830
830
|
const DATA_DIR = config.MCP_BRIDGE_DATA_DIR;
|
|
831
|
-
logger.debug(`[ADAPTER] Initializing Actual API for operation (budget: "${budget.name}", server: ${budget.serverUrl})`);
|
|
831
|
+
logger.debug(`[ADAPTER] Initializing Actual API for operation (budget: "${budget.name}", server: ${logSafeOrigin(budget.serverUrl)})`);
|
|
832
832
|
// Wrap api.init in auth-rate-limit retry so a transient too-many-requests
|
|
833
833
|
// doesn't surface to the caller (and doesn't trigger #132's crash path).
|
|
834
834
|
// Bound EACH init attempt with the op timeout (#270), inside withAuthRetry:
|
|
@@ -925,7 +925,7 @@ async function shutdownActualApi(opts) {
|
|
|
925
925
|
setApiInitialized(false);
|
|
926
926
|
}
|
|
927
927
|
}
|
|
928
|
-
import {
|
|
928
|
+
import { SCHEDULE_DATES_CAP, SPLIT_VERIFY_INTERVAL_MS, SPLIT_VERIFY_TIMEOUT_MS, WRITE_SESSION_DELAY_MS } from './constants.js';
|
|
929
929
|
// Concurrency limiter extracted to ./actual-adapter/concurrency.ts (#166).
|
|
930
930
|
// Imported for internal use (every method wraps its raw call in withConcurrency)
|
|
931
931
|
// and setMaxConcurrency is re-exported.
|
|
@@ -2958,6 +2958,56 @@ export async function getSchedules() {
|
|
|
2958
2958
|
return Array.isArray(raw) ? raw : [];
|
|
2959
2959
|
});
|
|
2960
2960
|
}
|
|
2961
|
+
/**
|
|
2962
|
+
* #479: the dates a schedule falls on, expanded by Actual itself (`api.getScheduleDates`, new in
|
|
2963
|
+
* 26.11.0), so month ends, patterns, intervals and the weekend move match the Actual UI.
|
|
2964
|
+
*
|
|
2965
|
+
* One session: the schedule listing and the expansion run inside the same withActualApi, never
|
|
2966
|
+
* through adapter.getSchedules (the api lock is not reentrant). Upstream takes the schedule row's
|
|
2967
|
+
* `date` field (`scheduleModel.toExternal` maps it from `_date`), a single date or a RecurConfig.
|
|
2968
|
+
* A count is ALWAYS sent, one above what is returned, so an open-ended recurrence returns its next
|
|
2969
|
+
* dates instead of upstream's "needs an end or a count" error, and the extra date is how
|
|
2970
|
+
* `truncated` knows that more dates exist after the last one returned. With both `end` and
|
|
2971
|
+
* `count`, upstream stops at whichever comes first.
|
|
2972
|
+
*
|
|
2973
|
+
* The two refusals a caller can fix are typed and checked BEFORE the upstream call, never chosen
|
|
2974
|
+
* by matching upstream message text: an `end` before `start` (only when both are given; with
|
|
2975
|
+
* `start` omitted upstream uses its own today and its error passes through), and a stored
|
|
2976
|
+
* `skipWeekend` without `weekendSolveMode`. Any other upstream error passes through unchanged.
|
|
2977
|
+
*/
|
|
2978
|
+
export async function getScheduleDates(scheduleId, opts = {}) {
|
|
2979
|
+
if (opts.start !== undefined && opts.end !== undefined && opts.end < opts.start) {
|
|
2980
|
+
throw new OutOfRangeRefusal(`end (${opts.end}) is before start (${opts.start}); end must be on or after start.`, opts.end);
|
|
2981
|
+
}
|
|
2982
|
+
return withActualApi(async () => {
|
|
2983
|
+
observability.incrementToolCall('actual.schedules.dates').catch(() => { });
|
|
2984
|
+
const schedules = await withConcurrency(() => retry(() => rawGetSchedules(), { retries: 2, backoffMs: 200 }));
|
|
2985
|
+
const schedule = Array.isArray(schedules) ? schedules.find((s) => s?.id === scheduleId) : undefined;
|
|
2986
|
+
if (!schedule) {
|
|
2987
|
+
throw new NotFoundRefusal('Schedule', scheduleId, 'actual_schedules_get');
|
|
2988
|
+
}
|
|
2989
|
+
const recur = schedule.date;
|
|
2990
|
+
if (recur && typeof recur === 'object' && recur.skipWeekend && !recur.weekendSolveMode) {
|
|
2991
|
+
throw new OutOfRangeRefusal(`Schedule ${scheduleId} has skipWeekend set without weekendSolveMode, so its dates cannot be ` +
|
|
2992
|
+
"computed. Set weekendSolveMode to 'before' or 'after' with actual_schedules_update.", 'weekendSolveMode');
|
|
2993
|
+
}
|
|
2994
|
+
const limit = Math.min(opts.count ?? SCHEDULE_DATES_CAP, SCHEDULE_DATES_CAP);
|
|
2995
|
+
const raw = await withConcurrency(() => rawGetScheduleDates(schedule.date, {
|
|
2996
|
+
...(opts.start !== undefined ? { start: opts.start } : {}),
|
|
2997
|
+
...(opts.end !== undefined ? { end: opts.end } : {}),
|
|
2998
|
+
count: limit + 1,
|
|
2999
|
+
}));
|
|
3000
|
+
const all = Array.isArray(raw) ? raw : [];
|
|
3001
|
+
const dates = all.slice(0, limit);
|
|
3002
|
+
return {
|
|
3003
|
+
schedule_id: scheduleId,
|
|
3004
|
+
name: typeof schedule.name === 'string' ? schedule.name : null,
|
|
3005
|
+
dates,
|
|
3006
|
+
count: dates.length,
|
|
3007
|
+
truncated: all.length > limit,
|
|
3008
|
+
};
|
|
3009
|
+
});
|
|
3010
|
+
}
|
|
2961
3011
|
/**
|
|
2962
3012
|
* #547: upstream builds a schedule's rule with one `is` condition per field and STRINGIFIES the
|
|
2963
3013
|
* value on create (`scheduleModel.fromExternal` in loot-core/src/server/api-models.ts), so an
|
|
@@ -3863,75 +3913,73 @@ export async function getFinancialAnalysisSnapshot(params) {
|
|
|
3863
3913
|
// Imported for internal use by runQuery and re-exported (unit-tested directly).
|
|
3864
3914
|
import { parseWhereClause, nameConditionLookups, nameConditionHintLines } from './actual-adapter/query.js';
|
|
3865
3915
|
export { parseWhereClause };
|
|
3916
|
+
function normaliseBankSyncReport(raw) {
|
|
3917
|
+
const r = (raw && typeof raw === 'object' ? raw : {});
|
|
3918
|
+
const list = (v) => (Array.isArray(v) ? v.map(String) : []);
|
|
3919
|
+
return {
|
|
3920
|
+
newTransactions: list(r.newTransactions),
|
|
3921
|
+
matchedTransactions: list(r.matchedTransactions),
|
|
3922
|
+
updatedAccounts: list(r.updatedAccounts),
|
|
3923
|
+
};
|
|
3924
|
+
}
|
|
3925
|
+
const BANK_SYNC_SETUP_HINT = 'To use bank sync, link your account(s) with a supported provider (GoCardless or SimpleFIN) in the Actual Budget UI. ' +
|
|
3926
|
+
'See https://actualbudget.org/docs/advanced/bank-sync for setup instructions.';
|
|
3866
3927
|
export async function runBankSync(accountId) {
|
|
3867
3928
|
try {
|
|
3868
|
-
|
|
3929
|
+
// #548: a write, so the import and the bank_sync_status row reach the server. In pooled
|
|
3930
|
+
// mode withActualApiWrite pushes once after a RESOLVED sync and never in a rejection path;
|
|
3931
|
+
// on the fallback (init/op/shutdown) path, shutdown still syncs whatever a partial
|
|
3932
|
+
// all-accounts run imported before it failed. Not queueWriteOperation: bank sync is one
|
|
3933
|
+
// upstream call, and the queue's per-call bound has no env knob, which #619 owns.
|
|
3934
|
+
return await withActualApiWrite(async () => {
|
|
3869
3935
|
observability.incrementToolCall('actual.bank.sync').catch(() => { });
|
|
3870
|
-
// Bank sync must NOT be retried
|
|
3936
|
+
// Bank sync must NOT be retried: retrying could import duplicate transactions.
|
|
3871
3937
|
// Pass { accountId } for a specific account, or {} to sync all linked accounts.
|
|
3872
3938
|
const args = accountId != null ? { accountId } : {};
|
|
3873
|
-
// Pre-check:
|
|
3874
|
-
//
|
|
3875
|
-
// which would otherwise be misreported as success and cause an unnecessary 30s wait.
|
|
3939
|
+
// Pre-check: upstream resolves silently for a local account and skips a closed one,
|
|
3940
|
+
// which would read as an empty but successful sync. Refusals are typed (#377).
|
|
3876
3941
|
if (accountId != null) {
|
|
3877
|
-
// Per-account check: verify the specified account is bank-linked.
|
|
3878
3942
|
const { data: acctRows } = await rawRunQuery(api.q('accounts')
|
|
3879
|
-
.select(['account_sync_source', 'name'])
|
|
3943
|
+
.select(['account_sync_source', 'account_id', 'name', 'closed'])
|
|
3880
3944
|
.filter({ id: accountId, tombstone: false }));
|
|
3881
3945
|
const acct = acctRows?.[0];
|
|
3882
3946
|
if (!acct) {
|
|
3883
|
-
|
|
3947
|
+
// Only reachable on a race: the tool resolves the id with verifyExists first (#388).
|
|
3948
|
+
throw new NotFoundRefusal('Account', accountId, 'actual_accounts_list');
|
|
3884
3949
|
}
|
|
3885
3950
|
if (!acct.account_sync_source) {
|
|
3886
|
-
throw new
|
|
3887
|
-
|
|
3888
|
-
|
|
3951
|
+
throw new OutOfRangeRefusal(`Bank sync failed: Account "${acct.name}" is a local account, not configured for bank sync. ${BANK_SYNC_SETUP_HINT}`, accountId);
|
|
3952
|
+
}
|
|
3953
|
+
if (acct.closed) {
|
|
3954
|
+
throw new OutOfRangeRefusal(`Bank sync failed: Account "${acct.name}" is closed; Actual skips closed accounts in bank sync. Reopen it in Actual to sync it.`, accountId);
|
|
3955
|
+
}
|
|
3956
|
+
// Upstream also skips an account without a provider account id (and one without a bank
|
|
3957
|
+
// row, which AQL does not expose, so that rarer half-link still reads as an empty sync).
|
|
3958
|
+
if (!acct.account_id) {
|
|
3959
|
+
throw new OutOfRangeRefusal(`Bank sync failed: Account "${acct.name}" has a ${acct.account_sync_source} link with no provider account; Actual skips it in bank sync. Re-link it in the Actual Budget UI.`, accountId);
|
|
3889
3960
|
}
|
|
3890
3961
|
}
|
|
3891
3962
|
else {
|
|
3892
|
-
// Global check: verify at least one bank-linked account exists across the budget.
|
|
3893
3963
|
const { data: allAccounts } = await rawRunQuery(api.q('accounts')
|
|
3894
|
-
.select(['account_sync_source'])
|
|
3964
|
+
.select(['account_sync_source', 'account_id', 'closed'])
|
|
3895
3965
|
.filter({ tombstone: false }));
|
|
3896
|
-
const
|
|
3897
|
-
if (
|
|
3898
|
-
throw new
|
|
3899
|
-
|
|
3900
|
-
`
|
|
3901
|
-
}
|
|
3902
|
-
}
|
|
3903
|
-
// rawRunBankSync returns void immediately; the actual provider call runs on
|
|
3904
|
-
// a background promise inside the SDK and surfaces errors as unhandledRejection.
|
|
3905
|
-
// We install a temporary listener to capture any BankSyncError and re-throw.
|
|
3906
|
-
let capturedRejection = null;
|
|
3907
|
-
const rejectionHandler = (reason) => {
|
|
3908
|
-
const msg = reason?.message || String(reason);
|
|
3909
|
-
if (reason?.type === 'BankSyncError' ||
|
|
3910
|
-
msg.includes('BankSyncError') ||
|
|
3911
|
-
msg.includes('NORDIGEN_ERROR') ||
|
|
3912
|
-
msg.includes('Rate limit exceeded') ||
|
|
3913
|
-
msg.includes('Failed syncing account') ||
|
|
3914
|
-
msg.includes('GoCardless') ||
|
|
3915
|
-
msg.includes('SimpleFIN')) {
|
|
3916
|
-
capturedRejection = reason;
|
|
3966
|
+
const linked = (allAccounts ?? []).filter(a => a.account_sync_source && a.account_id);
|
|
3967
|
+
if (!linked.some(a => !a.closed)) {
|
|
3968
|
+
throw new OutOfRangeRefusal(linked.length > 0
|
|
3969
|
+
? 'Bank sync failed: Every bank-linked account is closed; Actual skips closed accounts in bank sync. Reopen one in Actual to sync it.'
|
|
3970
|
+
: `Bank sync failed: No accounts are configured for bank sync. ${BANK_SYNC_SETUP_HINT}`, 'all');
|
|
3917
3971
|
}
|
|
3918
|
-
};
|
|
3919
|
-
process.on('unhandledRejection', rejectionHandler);
|
|
3920
|
-
try {
|
|
3921
|
-
await rawRunBankSync(args);
|
|
3922
|
-
// Wait for the SDK's background promise to resolve/reject.
|
|
3923
|
-
// Provider errors (rate limits, auth failures) arrive in < 3s in practice;
|
|
3924
|
-
// BANK_SYNC_SETTLE_MS gives a comfortable margin to catch them.
|
|
3925
|
-
await new Promise(resolve => setTimeout(resolve, BANK_SYNC_SETTLE_MS));
|
|
3926
|
-
if (capturedRejection !== null)
|
|
3927
|
-
throw capturedRejection;
|
|
3928
|
-
}
|
|
3929
|
-
finally {
|
|
3930
|
-
process.off('unhandledRejection', rejectionHandler);
|
|
3931
3972
|
}
|
|
3973
|
+
// Since 26.11 upstream awaits the provider and rejects this call itself, so the old
|
|
3974
|
+
// settle sleep and the process-wide unhandledRejection listener are gone (#548).
|
|
3975
|
+
return normaliseBankSyncReport(await rawRunBankSync(args));
|
|
3932
3976
|
});
|
|
3933
3977
|
}
|
|
3934
3978
|
catch (error) {
|
|
3979
|
+
// A typed refusal is already the caller-facing answer; the text mapping below would
|
|
3980
|
+
// rewrite "not configured" into a generic message and lose the brand.
|
|
3981
|
+
if (isPreflightRefusal(error))
|
|
3982
|
+
throw error;
|
|
3935
3983
|
const errorMsg = error?.message || String(error);
|
|
3936
3984
|
// Network / connectivity errors (includes "fetch failed" from Node.js native fetch)
|
|
3937
3985
|
if (errorMsg.includes('fetch failed') ||
|
|
@@ -3952,22 +4000,16 @@ export async function runBankSync(accountId) {
|
|
|
3952
4000
|
`To use bank sync, you must first link your account(s) with a supported provider (GoCardless or SimpleFIN) in the Actual Budget UI. ` +
|
|
3953
4001
|
`See https://actualbudget.org/docs/advanced/bank-sync for setup instructions.`);
|
|
3954
4002
|
}
|
|
3955
|
-
//
|
|
3956
|
-
//
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
errorMsg.includes('RATE_LIMIT_EXCEEDED') ||
|
|
3960
|
-
bankSyncCategory === 'RATE_LIMIT_EXCEEDED') {
|
|
3961
|
-
const reset = error?.details?.rateLimitHeaders?.http_x_ratelimit_account_success_reset;
|
|
3962
|
-
const retryIn = reset ? ` Retry in ~${Math.ceil(Number(reset) / 60)} minute(s).` : '';
|
|
3963
|
-
throw new Error(`Bank sync failed: GoCardless rate limit exceeded for this account.${retryIn} ` +
|
|
4003
|
+
// Provider errors. Since 26.11 the rejection is a plain Error with `.code` only (no
|
|
4004
|
+
// category, type or details), so the upstream message is the one rate-limit signal.
|
|
4005
|
+
if (errorMsg.includes('Rate limit exceeded')) {
|
|
4006
|
+
throw new Error(`Bank sync failed: GoCardless rate limit exceeded for this account. ` +
|
|
3964
4007
|
`(NORDIGEN RATE_LIMIT_EXCEEDED: account success quota exhausted)`);
|
|
3965
4008
|
}
|
|
3966
|
-
if (
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
throw new Error(`Bank sync failed: Provider error: ${errorMsg}`, { cause: error });
|
|
4009
|
+
if (errorMsg.includes('Failed syncing account')) {
|
|
4010
|
+
// The message names only the account; `.code` is the one hint at why (re-link or retry).
|
|
4011
|
+
const code = typeof error?.code === 'string' && error.code ? ` (code ${error.code})` : '';
|
|
4012
|
+
throw new Error(`Bank sync failed: Provider error: ${errorMsg}${code}`, { cause: error });
|
|
3971
4013
|
}
|
|
3972
4014
|
throw new Error(`Bank sync failed: ${errorMsg}`, { cause: error }); // #452: keep the brand
|
|
3973
4015
|
}
|
|
@@ -4036,6 +4078,9 @@ export async function switchBudget(name) {
|
|
|
4036
4078
|
};
|
|
4037
4079
|
if (!found)
|
|
4038
4080
|
throw notFound();
|
|
4081
|
+
// #628: tool results show the server origin only, never a userinfo credential, path or query.
|
|
4082
|
+
// `found` keeps the raw URL, which the login below still needs.
|
|
4083
|
+
const shownServerUrl = logSafeOrigin(found.serverUrl);
|
|
4039
4084
|
// ACL enforcement: the target budget must be in this session's allowedBudgets.
|
|
4040
4085
|
// OIDC mode: explicit ACL required. #572: any mode whose request carries a
|
|
4041
4086
|
// restricting list (MCP_STATIC_TOKENS) is checked against it too; with no list
|
|
@@ -4087,7 +4132,7 @@ export async function switchBudget(name) {
|
|
|
4087
4132
|
setSessionBudget(sessionId, key);
|
|
4088
4133
|
setPreferredBudgetSyncId(store?.principal, found.syncId); // #189: persist at commit
|
|
4089
4134
|
logger.info(`[ADAPTER] switchBudget no-op for session ${sessionId}: already on "${found.name}" (${found.syncId})`);
|
|
4090
|
-
return { name: found.name, syncId: found.syncId, serverUrl:
|
|
4135
|
+
return { name: found.name, syncId: found.syncId, serverUrl: shownServerUrl };
|
|
4091
4136
|
}
|
|
4092
4137
|
if (sameAuth) {
|
|
4093
4138
|
// Same server + creds, different syncId. Reload budget file in place.
|
|
@@ -4108,8 +4153,8 @@ export async function switchBudget(name) {
|
|
|
4108
4153
|
connectionPool.updateLoadedSyncId(sessionId, found.syncId);
|
|
4109
4154
|
setSessionBudget(sessionId, key);
|
|
4110
4155
|
setPreferredBudgetSyncId(store?.principal, found.syncId); // #189: persist at commit
|
|
4111
|
-
logger.info(`[ADAPTER] Active budget switched for session ${sessionId} to: "${found.name}" (${found.syncId}) on ${found.serverUrl}`);
|
|
4112
|
-
return { name: found.name, syncId: found.syncId, serverUrl:
|
|
4156
|
+
logger.info(`[ADAPTER] Active budget switched for session ${sessionId} to: "${found.name}" (${found.syncId}) on ${logSafeOrigin(found.serverUrl)}`);
|
|
4157
|
+
return { name: found.name, syncId: found.syncId, serverUrl: shownServerUrl };
|
|
4113
4158
|
}
|
|
4114
4159
|
// #581: hold this session's slot across the window between releasing the old entry and
|
|
4115
4160
|
// creating the new one, so a concurrent initialize cannot take it and leave this session over
|
|
@@ -4178,8 +4223,8 @@ export async function switchBudget(name) {
|
|
|
4178
4223
|
if (holdsSlot)
|
|
4179
4224
|
connectionPool.releaseReservation(sessionId);
|
|
4180
4225
|
}
|
|
4181
|
-
logger.info(`[ADAPTER] Active budget switched for session ${sessionId} to: "${found.name}" (${found.syncId}) on ${found.serverUrl}`);
|
|
4182
|
-
return { name: found.name, syncId: found.syncId, serverUrl:
|
|
4226
|
+
logger.info(`[ADAPTER] Active budget switched for session ${sessionId} to: "${found.name}" (${found.syncId}) on ${logSafeOrigin(found.serverUrl)}`);
|
|
4227
|
+
return { name: found.name, syncId: found.syncId, serverUrl: shownServerUrl };
|
|
4183
4228
|
}
|
|
4184
4229
|
/**
|
|
4185
4230
|
* Clear the per-session budget state for a session, so the map does not
|
|
@@ -4200,7 +4245,8 @@ export function getBudgetRegistry() {
|
|
|
4200
4245
|
return [...budgetRegistry.values()].filter(b => !allowed || allowed.includes(b.syncId)).map(b => ({
|
|
4201
4246
|
name: b.name,
|
|
4202
4247
|
syncId: b.syncId,
|
|
4203
|
-
|
|
4248
|
+
// #628: origin only; the registry entry itself keeps the raw URL for the login.
|
|
4249
|
+
serverUrl: logSafeOrigin(b.serverUrl),
|
|
4204
4250
|
hasEncryption: !!b.encryptionPassword,
|
|
4205
4251
|
}));
|
|
4206
4252
|
}
|
|
@@ -4573,6 +4619,7 @@ export default {
|
|
|
4573
4619
|
getIDByName,
|
|
4574
4620
|
getServerVersion,
|
|
4575
4621
|
getSchedules,
|
|
4622
|
+
getScheduleDates,
|
|
4576
4623
|
createSchedule,
|
|
4577
4624
|
updateSchedule,
|
|
4578
4625
|
deleteSchedule,
|
package/dist/src/lib/apiLogin.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import api from '@actual-app/api';
|
|
2
|
+
import config from '../config.js';
|
|
2
3
|
import { withOpTimeout } from './opTimeout.js';
|
|
3
4
|
import { setApiLogin, setLoadedBudgetSyncId, registerBudgetLoad, loginsSpanServers } from './apiState.js';
|
|
4
5
|
import { createModuleLogger } from './loggerFactory.js';
|
|
@@ -38,6 +39,10 @@ let upstreamLib = null;
|
|
|
38
39
|
* entries are still valid.
|
|
39
40
|
* 3. log in, then record the login.
|
|
40
41
|
*
|
|
42
|
+
* Every init passes `verbose` explicitly (#620): upstream only sets its process-global verbose
|
|
43
|
+
* mode when the key is present, and its default is on, which writes reconciled transactions into
|
|
44
|
+
* our log through `wireConsoleAndDebug`.
|
|
45
|
+
*
|
|
41
46
|
* Bounded by the op timeout (#270). Callers that retry on the auth rate limit wrap this whole
|
|
42
47
|
* call in `withAuthRetry`, so each attempt is bounded on its own.
|
|
43
48
|
*
|
|
@@ -72,6 +77,7 @@ export async function initApiLoggedIn(dataDir, login, label) {
|
|
|
72
77
|
dataDir,
|
|
73
78
|
serverURL: login.serverUrl,
|
|
74
79
|
password: login.password,
|
|
80
|
+
verbose: config.ACTUAL_API_VERBOSE,
|
|
75
81
|
});
|
|
76
82
|
const lib = await withOpTimeout(() => loggingIn, label);
|
|
77
83
|
if (lib && typeof lib.send === 'function')
|
|
@@ -109,7 +115,7 @@ async function initOverFetchedLogin(dataDir, login, label) {
|
|
|
109
115
|
}
|
|
110
116
|
}
|
|
111
117
|
// `password: ''` is upstream's no-credential init (it signs in only on a truthy password).
|
|
112
|
-
const pointing = api.init({ dataDir, serverURL: login.serverUrl, password: '' });
|
|
118
|
+
const pointing = api.init({ dataDir, serverURL: login.serverUrl, password: '', verbose: config.ACTUAL_API_VERBOSE });
|
|
113
119
|
registerBudgetLoad(pointing);
|
|
114
120
|
const lib = await withOpTimeout(() => pointing, label);
|
|
115
121
|
if (!lib || typeof lib.send !== 'function')
|
|
@@ -218,3 +218,20 @@ export function enforceHttpAuthPosture(input, log = logger, exit = process.exit)
|
|
|
218
218
|
}
|
|
219
219
|
return decision;
|
|
220
220
|
}
|
|
221
|
+
/**
|
|
222
|
+
* One word naming the HTTP auth mode for the startup banner (#626). Precedence
|
|
223
|
+
* mirrors the listen callback's posture lines in httpServer.ts: OIDC, then the
|
|
224
|
+
* static token map, then the single shared bearer token, then none. With both
|
|
225
|
+
* token variables set this reports `static`, as the ticket specifies; the
|
|
226
|
+
* listen callback still names the extra unrestricted token, and #623 replaces
|
|
227
|
+
* the banner. Never prints an issuer, token or principal.
|
|
228
|
+
*/
|
|
229
|
+
export function describeAuthMode(input) {
|
|
230
|
+
if (input.oidcEnabled)
|
|
231
|
+
return 'oidc';
|
|
232
|
+
if (input.hasStaticTokens)
|
|
233
|
+
return 'static';
|
|
234
|
+
if (input.hasSseAuthorization)
|
|
235
|
+
return 'bearer';
|
|
236
|
+
return 'none';
|
|
237
|
+
}
|