bankmcp 0.1.0
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/LICENSE +21 -0
- package/README.md +318 -0
- package/bin/bankmcp.js +8 -0
- package/package.json +43 -0
- package/plugin/.claude-plugin/plugin.json +9 -0
- package/plugin/.mcp.json +8 -0
- package/plugin/skills/bank/SKILL.md +70 -0
- package/src/app.ts +176 -0
- package/src/auth.ts +231 -0
- package/src/cli.ts +92 -0
- package/src/config.ts +142 -0
- package/src/data.ts +89 -0
- package/src/enablebanking.ts +171 -0
- package/src/local.ts +50 -0
- package/src/mcp.ts +23 -0
- package/src/pages.ts +182 -0
- package/src/prompts.ts +127 -0
- package/src/server.ts +16 -0
- package/src/setup.ts +46 -0
- package/src/stdio.ts +15 -0
- package/src/store.ts +258 -0
- package/src/tools.ts +377 -0
- package/src/watcher.ts +173 -0
package/src/auth.ts
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// A complete OAuth 2.1 authorization server with exactly one user: you.
|
|
2
|
+
// The MCP SDK provides discovery, dynamic client registration, PKCE checks
|
|
3
|
+
// and the token endpoint; this file supplies the storage behind them and a
|
|
4
|
+
// password login page. Tokens are stored hashed.
|
|
5
|
+
import { createHash, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
|
|
6
|
+
import type { Response } from "express";
|
|
7
|
+
import type { OAuthServerProvider, AuthorizationParams } from "@modelcontextprotocol/sdk/server/auth/provider.js";
|
|
8
|
+
import type { OAuthRegisteredClientsStore } from "@modelcontextprotocol/sdk/server/auth/clients.js";
|
|
9
|
+
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
|
|
10
|
+
import type { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
11
|
+
import { InvalidGrantError, InvalidClientError, InvalidClientMetadataError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
|
|
12
|
+
import { config } from "./config.ts";
|
|
13
|
+
import { loginPage } from "./pages.ts";
|
|
14
|
+
export { loginPage, shell as page } from "./pages.ts";
|
|
15
|
+
import type { Store, OAuthClient } from "./store.ts";
|
|
16
|
+
|
|
17
|
+
const ACCESS_TTL = 60 * 60; // 1 hour
|
|
18
|
+
const REFRESH_TTL = 90 * 24 * 60 * 60; // 90 days
|
|
19
|
+
const CODE_TTL = 10 * 60;
|
|
20
|
+
const LOGIN_TTL = 30 * 60;
|
|
21
|
+
|
|
22
|
+
// --- Password ---
|
|
23
|
+
|
|
24
|
+
export function hashPassword(password: string): string {
|
|
25
|
+
const salt = randomBytes(16);
|
|
26
|
+
const hash = scryptSync(password, salt, 64);
|
|
27
|
+
return `scrypt$${salt.toString("base64")}$${hash.toString("base64")}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function verifyPassword(password: string): boolean {
|
|
31
|
+
if (config.adminPasswordHash) {
|
|
32
|
+
const [scheme, salt, expected] = config.adminPasswordHash.split("$");
|
|
33
|
+
if (scheme !== "scrypt" || !salt || !expected) return false;
|
|
34
|
+
const actual = scryptSync(password, Buffer.from(salt, "base64"), 64);
|
|
35
|
+
const exp = Buffer.from(expected, "base64");
|
|
36
|
+
return actual.length === exp.length && timingSafeEqual(actual, exp);
|
|
37
|
+
}
|
|
38
|
+
if (config.adminPassword) {
|
|
39
|
+
const a = Buffer.from(password);
|
|
40
|
+
const b = Buffer.from(config.adminPassword);
|
|
41
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function redirectAllowed(uri: string): boolean {
|
|
47
|
+
try {
|
|
48
|
+
const host = new URL(uri).hostname.toLowerCase();
|
|
49
|
+
return config.allowedRedirectHosts.some((h) => host === h || host.endsWith(`.${h}`));
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const sha256 = (s: string) => createHash("sha256").update(s).digest("hex");
|
|
56
|
+
const token = () => randomBytes(32).toString("base64url");
|
|
57
|
+
const now = () => Math.floor(Date.now() / 1000);
|
|
58
|
+
|
|
59
|
+
// Authorization requests waiting for the password, keyed by a one-time id
|
|
60
|
+
// embedded in the login form. Memory only: they live ten minutes.
|
|
61
|
+
interface PendingLogin {
|
|
62
|
+
client: OAuthClientInformationFull;
|
|
63
|
+
params: AuthorizationParams;
|
|
64
|
+
expires: number;
|
|
65
|
+
attempts: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface LoginEvent {
|
|
69
|
+
ok: boolean;
|
|
70
|
+
ip: string;
|
|
71
|
+
clientName?: string;
|
|
72
|
+
reason?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class SingleUserProvider implements OAuthServerProvider {
|
|
76
|
+
private pendingLogins = new Map<string, PendingLogin>();
|
|
77
|
+
private failures = new Map<string, { count: number; until: number }>();
|
|
78
|
+
private store: Store;
|
|
79
|
+
private onLogin?: (e: LoginEvent) => void;
|
|
80
|
+
|
|
81
|
+
constructor(store: Store, opts: { onLogin?: (e: LoginEvent) => void } = {}) {
|
|
82
|
+
this.store = store;
|
|
83
|
+
this.onLogin = opts.onLogin;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Every token and pending code is dropped. Used when the admin password changes. */
|
|
87
|
+
revokeAll(): void {
|
|
88
|
+
this.pendingLogins.clear();
|
|
89
|
+
this.store.update((d) => {
|
|
90
|
+
d.oauth.tokens = {};
|
|
91
|
+
d.oauth.codes = {};
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
get clientsStore(): OAuthRegisteredClientsStore {
|
|
96
|
+
const store = this.store;
|
|
97
|
+
return {
|
|
98
|
+
getClient(clientId) {
|
|
99
|
+
return store.data.oauth.clients[clientId] as OAuthClientInformationFull | undefined;
|
|
100
|
+
},
|
|
101
|
+
registerClient(client) {
|
|
102
|
+
for (const uri of client.redirect_uris) {
|
|
103
|
+
if (!redirectAllowed(uri)) throw new InvalidClientMetadataError(`redirect_uri host not allowed: ${new URL(uri).hostname}. Set ALLOWED_REDIRECT_HOSTS on the server to permit it.`);
|
|
104
|
+
}
|
|
105
|
+
// The SDK handler has already generated the id and, for confidential clients, the secret.
|
|
106
|
+
const incoming = client as Partial<OAuthClient>;
|
|
107
|
+
const full: OAuthClient = {
|
|
108
|
+
...(client as OAuthClient),
|
|
109
|
+
client_id: incoming.client_id ?? randomBytes(16).toString("hex"),
|
|
110
|
+
client_id_issued_at: incoming.client_id_issued_at ?? now(),
|
|
111
|
+
};
|
|
112
|
+
store.update((d) => {
|
|
113
|
+
// Keep the store small: a connector re-registers when it is re-added.
|
|
114
|
+
const ids = Object.keys(d.oauth.clients);
|
|
115
|
+
if (ids.length > 20) for (const id of ids.slice(0, ids.length - 20)) delete d.oauth.clients[id];
|
|
116
|
+
d.oauth.clients[full.client_id] = full;
|
|
117
|
+
});
|
|
118
|
+
return full as OAuthClientInformationFull;
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
|
|
124
|
+
this.sweep();
|
|
125
|
+
const id = token();
|
|
126
|
+
this.pendingLogins.set(id, { client, params, expires: now() + LOGIN_TTL, attempts: 0 });
|
|
127
|
+
res.status(200).type("html").send(loginPage({ requestId: id, clientName: client.client_name, returnTo: new URL(params.redirectUri).hostname }));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Called by POST /login. Returns the redirect URL on success, or an error message. */
|
|
131
|
+
completeLogin(requestId: string, password: string, ip: string): { redirect: string } | { error: string; requestId?: string } {
|
|
132
|
+
this.sweep();
|
|
133
|
+
const lock = this.failures.get(ip);
|
|
134
|
+
if (lock && lock.until > now()) {
|
|
135
|
+
this.onLogin?.({ ok: false, ip, reason: "locked out" });
|
|
136
|
+
return { error: "Too many attempts. Try again in a few minutes." };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const pending = this.pendingLogins.get(requestId);
|
|
140
|
+
if (!pending) return { error: "This sign-in page has expired or the server restarted. Go back to your assistant, click Connect again, and enter the password within 30 minutes." };
|
|
141
|
+
|
|
142
|
+
if (!verifyPassword(password)) {
|
|
143
|
+
pending.attempts += 1;
|
|
144
|
+
const f = this.failures.get(ip) ?? { count: 0, until: 0 };
|
|
145
|
+
f.count += 1;
|
|
146
|
+
if (f.count >= 5) f.until = now() + 15 * 60;
|
|
147
|
+
this.failures.set(ip, f);
|
|
148
|
+
if (pending.attempts >= 5) this.pendingLogins.delete(requestId);
|
|
149
|
+
this.onLogin?.({ ok: false, ip, clientName: pending.client.client_name, reason: "wrong password" });
|
|
150
|
+
return { error: "Wrong password.", requestId: pending.attempts < 5 ? requestId : undefined };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
this.pendingLogins.delete(requestId);
|
|
154
|
+
this.failures.delete(ip);
|
|
155
|
+
this.onLogin?.({ ok: true, ip, clientName: pending.client.client_name });
|
|
156
|
+
const code = token();
|
|
157
|
+
this.store.update((d) => {
|
|
158
|
+
for (const [c, v] of Object.entries(d.oauth.codes)) if (v.expires < now()) delete d.oauth.codes[c];
|
|
159
|
+
d.oauth.codes[sha256(code)] = {
|
|
160
|
+
client_id: pending.client.client_id,
|
|
161
|
+
code_challenge: pending.params.codeChallenge,
|
|
162
|
+
redirect_uri: pending.params.redirectUri,
|
|
163
|
+
resource: pending.params.resource?.href,
|
|
164
|
+
scopes: pending.params.scopes ?? [],
|
|
165
|
+
expires: now() + CODE_TTL,
|
|
166
|
+
};
|
|
167
|
+
});
|
|
168
|
+
const url = new URL(pending.params.redirectUri);
|
|
169
|
+
url.searchParams.set("code", code);
|
|
170
|
+
if (pending.params.state) url.searchParams.set("state", pending.params.state);
|
|
171
|
+
return { redirect: url.href };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<string> {
|
|
175
|
+
const c = this.store.data.oauth.codes[sha256(authorizationCode)];
|
|
176
|
+
if (!c || c.client_id !== client.client_id || c.expires < now()) throw new InvalidGrantError("Invalid or expired authorization code");
|
|
177
|
+
return c.code_challenge;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string, _codeVerifier?: string, redirectUri?: string, resource?: URL): Promise<OAuthTokens> {
|
|
181
|
+
const key = sha256(authorizationCode);
|
|
182
|
+
const c = this.store.data.oauth.codes[key];
|
|
183
|
+
if (!c || c.client_id !== client.client_id || c.expires < now()) throw new InvalidGrantError("Invalid or expired authorization code");
|
|
184
|
+
if (redirectUri && redirectUri !== c.redirect_uri) throw new InvalidGrantError("redirect_uri does not match");
|
|
185
|
+
if (resource && c.resource && resource.href !== c.resource) throw new InvalidGrantError("resource does not match");
|
|
186
|
+
return this.store.update((d) => {
|
|
187
|
+
delete d.oauth.codes[key];
|
|
188
|
+
return this.issue(d.oauth.tokens, client.client_id, c.scopes, c.resource);
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[], resource?: URL): Promise<OAuthTokens> {
|
|
193
|
+
const key = sha256(refreshToken);
|
|
194
|
+
const t = this.store.data.oauth.tokens[key];
|
|
195
|
+
if (!t || t.kind !== "refresh" || t.client_id !== client.client_id) throw new InvalidGrantError("Invalid refresh token");
|
|
196
|
+
if (t.expires < now()) throw new InvalidGrantError("Refresh token expired");
|
|
197
|
+
if (resource && t.resource && resource.href !== t.resource) throw new InvalidGrantError("resource does not match");
|
|
198
|
+
return this.store.update((d) => {
|
|
199
|
+
delete d.oauth.tokens[key];
|
|
200
|
+
return this.issue(d.oauth.tokens, client.client_id, scopes?.length ? scopes : t.scopes, t.resource);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async verifyAccessToken(tokenValue: string): Promise<AuthInfo> {
|
|
205
|
+
const t = this.store.data.oauth.tokens[sha256(tokenValue)];
|
|
206
|
+
if (!t || t.kind !== "access") throw new InvalidClientError("Invalid access token");
|
|
207
|
+
if (t.expires < now()) throw new InvalidClientError("Access token expired");
|
|
208
|
+
return { token: tokenValue, clientId: t.client_id, scopes: t.scopes, expiresAt: t.expires, resource: t.resource ? new URL(t.resource) : undefined };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async revokeToken(client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise<void> {
|
|
212
|
+
const key = sha256(request.token);
|
|
213
|
+
const t = this.store.data.oauth.tokens[key];
|
|
214
|
+
if (t && t.client_id === client.client_id) this.store.update((d) => void delete d.oauth.tokens[key]);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private issue(tokens: Record<string, import("./store.ts").Token>, clientId: string, scopes: string[], resource?: string): OAuthTokens {
|
|
218
|
+
for (const [k, v] of Object.entries(tokens)) if (v.expires < now()) delete tokens[k];
|
|
219
|
+
const access = token();
|
|
220
|
+
const refresh = token();
|
|
221
|
+
tokens[sha256(access)] = { kind: "access", client_id: clientId, scopes, resource, expires: now() + ACCESS_TTL };
|
|
222
|
+
tokens[sha256(refresh)] = { kind: "refresh", client_id: clientId, scopes, resource, expires: now() + REFRESH_TTL };
|
|
223
|
+
return { access_token: access, token_type: "bearer", expires_in: ACCESS_TTL, refresh_token: refresh, scope: scopes.join(" ") || undefined };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private sweep() {
|
|
227
|
+
const t = now();
|
|
228
|
+
for (const [k, v] of this.pendingLogins) if (v.expires < t) this.pendingLogins.delete(k);
|
|
229
|
+
for (const [k, v] of this.failures) if (v.until && v.until < t) this.failures.delete(k);
|
|
230
|
+
}
|
|
231
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Helpers for setting up and checking a deployment:
|
|
2
|
+
// node src/cli.ts hash-password → value for ADMIN_PASSWORD_HASH
|
|
3
|
+
// node src/cli.ts check → verifies config and the Enable Banking application
|
|
4
|
+
// node src/cli.ts watch [--force] → runs all watches once and prints what fired
|
|
5
|
+
import { createInterface } from "node:readline";
|
|
6
|
+
import { config, setupProblems } from "./config.ts";
|
|
7
|
+
import { eb, EnableBankingError } from "./enablebanking.ts";
|
|
8
|
+
import { hashPassword } from "./auth.ts";
|
|
9
|
+
import { store } from "./store.ts";
|
|
10
|
+
import { daysLeft } from "./data.ts";
|
|
11
|
+
import { runWatches } from "./watcher.ts";
|
|
12
|
+
|
|
13
|
+
const [command, ...args] = process.argv.slice(2);
|
|
14
|
+
|
|
15
|
+
const CTRL_C = "\u0003";
|
|
16
|
+
const BACKSPACE = "\u007f";
|
|
17
|
+
|
|
18
|
+
async function askHidden(question: string): Promise<string> {
|
|
19
|
+
if (!process.stdin.isTTY) {
|
|
20
|
+
const rl = createInterface({ input: process.stdin });
|
|
21
|
+
for await (const line of rl) return line;
|
|
22
|
+
return "";
|
|
23
|
+
}
|
|
24
|
+
process.stdout.write(question);
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
let buf = "";
|
|
27
|
+
process.stdin.setRawMode(true);
|
|
28
|
+
process.stdin.resume();
|
|
29
|
+
process.stdin.setEncoding("utf8");
|
|
30
|
+
const onData = (ch: string) => {
|
|
31
|
+
for (const c of ch) {
|
|
32
|
+
if (c === "\r" || c === "\n") {
|
|
33
|
+
process.stdin.setRawMode(false);
|
|
34
|
+
process.stdin.pause();
|
|
35
|
+
process.stdin.off("data", onData);
|
|
36
|
+
process.stdout.write("\n");
|
|
37
|
+
resolve(buf);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (c === CTRL_C) process.exit(1);
|
|
41
|
+
if (c === BACKSPACE || c === "\b") buf = buf.slice(0, -1);
|
|
42
|
+
else buf += c;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
process.stdin.on("data", onData);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
switch (command) {
|
|
50
|
+
case "hash-password": {
|
|
51
|
+
const pw = args[0] ?? (await askHidden("Password: "));
|
|
52
|
+
if (pw.length < 8) {
|
|
53
|
+
console.error("Use at least 8 characters.");
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
console.log(hashPassword(pw));
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
case "check": {
|
|
60
|
+
const problems = setupProblems();
|
|
61
|
+
if (problems.length) {
|
|
62
|
+
console.log("Configuration problems:");
|
|
63
|
+
for (const p of problems) console.log(` - ${p}`);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
console.log(`Config OK. Public URL: ${config.baseUrl}`);
|
|
67
|
+
try {
|
|
68
|
+
const appInfo = await eb.getApplication();
|
|
69
|
+
console.log(`Enable Banking application: ${appInfo.name} (${appInfo.environment}, ${appInfo.active ? "active" : "INACTIVE"})`);
|
|
70
|
+
const cb = `${config.baseUrl}/callback`;
|
|
71
|
+
if (appInfo.redirect_urls.includes(cb)) console.log(`Redirect URL registered: ${cb}`);
|
|
72
|
+
else console.log(`WARNING: ${cb} is not among the application's redirect URLs (${appInfo.redirect_urls.join(", ") || "none"}). Add it in the Control Panel.`);
|
|
73
|
+
if (!appInfo.active) console.log("The application is inactive. For your own accounts, click 'Activate by linking accounts' in the Control Panel.");
|
|
74
|
+
} catch (err) {
|
|
75
|
+
console.log(`Enable Banking call failed: ${err instanceof EnableBankingError ? `${err.status} ${err.body.slice(0, 200)}` : (err as Error).message}`);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
const s = store();
|
|
79
|
+
console.log(`Store: ${s.path}`);
|
|
80
|
+
for (const x of s.sessions()) console.log(` ${x.bank.name}: ${s.accounts().filter((a) => a.session_id === x.id).length} account(s), consent ${daysLeft(x.valid_until)} days left`);
|
|
81
|
+
if (!s.sessions().length) console.log(" no banks connected yet");
|
|
82
|
+
console.log(`Watches: ${s.watches().length}, webhook ${config.notifyWebhookUrl ? "configured" : "not set"}`);
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
case "watch": {
|
|
86
|
+
console.log(JSON.stringify(await runWatches({ force: args.includes("--force") }), null, 2));
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
default:
|
|
90
|
+
console.log("Usage: node src/cli.ts <hash-password [password] | check | watch [--force]>");
|
|
91
|
+
process.exit(command ? 1 : 0);
|
|
92
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Configuration comes from two places. Environment variables always win.
|
|
2
|
+
// Anything missing is read from the data directory, where the first-run
|
|
3
|
+
// setup page stores the application id, the key file and the password hash.
|
|
4
|
+
import { accessSync, constants, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
|
|
8
|
+
const env = process.env;
|
|
9
|
+
// Local mode: the server is launched by an MCP client on the user's own
|
|
10
|
+
// machine over stdio. No OAuth, no admin password; state lives in ~/.bankmcp.
|
|
11
|
+
const localMode = env.BANKMCP_LOCAL === "1";
|
|
12
|
+
const port = Number(env.PORT ?? 8080);
|
|
13
|
+
const dataDir = env.DATA_DIR ?? (localMode ? join(homedir(), ".bankmcp") : "./data");
|
|
14
|
+
|
|
15
|
+
export interface Settings {
|
|
16
|
+
app_id?: string;
|
|
17
|
+
admin_password_hash?: string;
|
|
18
|
+
country?: string;
|
|
19
|
+
setup_completed?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const settingsPath = join(dataDir, "settings.json");
|
|
23
|
+
const keyPath = join(dataDir, "enablebanking.pem");
|
|
24
|
+
let settings: Settings = readSettings();
|
|
25
|
+
|
|
26
|
+
function readSettings(): Settings {
|
|
27
|
+
try {
|
|
28
|
+
return existsSync(settingsPath) ? (JSON.parse(readFileSync(settingsPath, "utf8")) as Settings) : {};
|
|
29
|
+
} catch {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function saveSettings(patch: Settings): void {
|
|
35
|
+
mkdirSync(dataDir, { recursive: true });
|
|
36
|
+
settings = { ...settings, ...patch };
|
|
37
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2), { mode: 0o600 });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function saveKeyFile(pem: string): void {
|
|
41
|
+
mkdirSync(dataDir, { recursive: true });
|
|
42
|
+
writeFileSync(keyPath, pem.trim() + "\n", { mode: 0o600 });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Public URL guessed from the platform when BASE_URL is not set. */
|
|
46
|
+
function detectBaseUrl(): string {
|
|
47
|
+
if (env.BASE_URL) return env.BASE_URL.replace(/\/+$/, "");
|
|
48
|
+
if (env.RAILWAY_PUBLIC_DOMAIN) return `https://${env.RAILWAY_PUBLIC_DOMAIN}`;
|
|
49
|
+
if (env.FLY_APP_NAME) return `https://${env.FLY_APP_NAME}.fly.dev`;
|
|
50
|
+
if (localMode) return `https://localhost:${port}`;
|
|
51
|
+
return `http://localhost:${port}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const config = {
|
|
55
|
+
localMode,
|
|
56
|
+
get appId(): string {
|
|
57
|
+
return env.EB_APP_ID ?? settings.app_id ?? "";
|
|
58
|
+
},
|
|
59
|
+
get privateKey(): string {
|
|
60
|
+
return env.EB_PRIVATE_KEY ?? "";
|
|
61
|
+
},
|
|
62
|
+
get privateKeyPath(): string {
|
|
63
|
+
if (env.EB_PRIVATE_KEY_PATH) return env.EB_PRIVATE_KEY_PATH;
|
|
64
|
+
return existsSync(keyPath) ? keyPath : "";
|
|
65
|
+
},
|
|
66
|
+
apiBase: env.EB_API_BASE ?? "https://api.enablebanking.com",
|
|
67
|
+
get country(): string {
|
|
68
|
+
return (env.DEFAULT_COUNTRY ?? settings.country ?? "DK").toUpperCase();
|
|
69
|
+
},
|
|
70
|
+
port,
|
|
71
|
+
baseUrl: detectBaseUrl(),
|
|
72
|
+
dataDir,
|
|
73
|
+
appName: env.APP_NAME ?? "BankMCP™",
|
|
74
|
+
get adminPasswordHash(): string {
|
|
75
|
+
return env.ADMIN_PASSWORD_HASH ?? settings.admin_password_hash ?? "";
|
|
76
|
+
},
|
|
77
|
+
adminPassword: env.ADMIN_PASSWORD ?? "",
|
|
78
|
+
notifyWebhookUrl: env.NOTIFY_WEBHOOK_URL ?? "",
|
|
79
|
+
// Hosts an OAuth client may send the sign-in back to. Stops a phishing link
|
|
80
|
+
// from registering a client that redirects your authorization code elsewhere.
|
|
81
|
+
// Defaults cover the well-known MCP clients; subdomains are included.
|
|
82
|
+
allowedRedirectHosts: (env.ALLOWED_REDIRECT_HOSTS ?? "claude.ai,claude.com,chatgpt.com,openai.com,mistral.ai,cursor.com,cursor.sh,vscode.dev,localhost,127.0.0.1")
|
|
83
|
+
.split(",")
|
|
84
|
+
.map((h) => h.trim().toLowerCase())
|
|
85
|
+
.filter(Boolean),
|
|
86
|
+
// Optional: terminate TLS in the process itself (for running on your own
|
|
87
|
+
// machine). Hosted deployments normally get TLS from the platform.
|
|
88
|
+
tlsCertPath: env.TLS_CERT_PATH ?? "",
|
|
89
|
+
tlsKeyPath: env.TLS_KEY_PATH ?? "",
|
|
90
|
+
// Unattended polling for watches: PSD2 allows at most four account accesses
|
|
91
|
+
// per day without the account holder present.
|
|
92
|
+
pollIntervalHours: Number(env.POLL_INTERVAL_HOURS ?? 6),
|
|
93
|
+
/** True when every secret came from the environment, so the setup page has nothing to do. */
|
|
94
|
+
get lockedByEnv(): boolean {
|
|
95
|
+
return Boolean(env.EB_APP_ID && (env.EB_PRIVATE_KEY || env.EB_PRIVATE_KEY_PATH) && (localMode || env.ADMIN_PASSWORD_HASH || env.ADMIN_PASSWORD));
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export function tlsOptions(): { cert: string; key: string } | undefined {
|
|
100
|
+
if (!config.tlsCertPath || !config.tlsKeyPath) return undefined;
|
|
101
|
+
return { cert: readFileSync(config.tlsCertPath, "utf8"), key: readFileSync(config.tlsKeyPath, "utf8") };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const looksLikeUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
105
|
+
|
|
106
|
+
/** Human-readable list of what is still missing before the server can talk to banks. */
|
|
107
|
+
export function setupProblems(): string[] {
|
|
108
|
+
const problems: string[] = [];
|
|
109
|
+
if (!config.appId) problems.push("Enable Banking application id is not set");
|
|
110
|
+
else if (!looksLikeUuid.test(config.appId)) problems.push("EB_APP_ID does not look like a UUID");
|
|
111
|
+
if (!config.privateKey && !config.privateKeyPath) problems.push("Enable Banking private key is not set");
|
|
112
|
+
else {
|
|
113
|
+
try {
|
|
114
|
+
const pem = readPrivateKey();
|
|
115
|
+
if (!/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(pem)) problems.push("The private key is not a PEM file (expected -----BEGIN PRIVATE KEY-----)");
|
|
116
|
+
} catch (err) {
|
|
117
|
+
problems.push(`Cannot read private key: ${(err as Error).message}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (!localMode && !config.adminPasswordHash && !config.adminPassword) problems.push("Admin password is not set");
|
|
121
|
+
if (!/^https?:\/\//.test(config.baseUrl)) problems.push("BASE_URL must start with http:// or https://");
|
|
122
|
+
try {
|
|
123
|
+
mkdirSync(config.dataDir, { recursive: true });
|
|
124
|
+
accessSync(config.dataDir, constants.W_OK);
|
|
125
|
+
} catch {
|
|
126
|
+
problems.push(`DATA_DIR ${config.dataDir} is not writable by this process (check volume permissions)`);
|
|
127
|
+
}
|
|
128
|
+
return problems;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function isConfigured(): boolean {
|
|
132
|
+
return setupProblems().length === 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function readPrivateKey(): string {
|
|
136
|
+
if (config.privateKey) {
|
|
137
|
+
const raw = config.privateKey.trim();
|
|
138
|
+
if (raw.startsWith("-----")) return raw.replace(/\\n/g, "\n");
|
|
139
|
+
return Buffer.from(raw, "base64").toString("utf8");
|
|
140
|
+
}
|
|
141
|
+
return readFileSync(config.privateKeyPath, "utf8");
|
|
142
|
+
}
|
package/src/data.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Shapes the API responses into what an assistant actually needs: signed
|
|
2
|
+
// amounts, one counterparty field, one description field, and a booked vs
|
|
3
|
+
// available balance instead of a list of ISO balance codes.
|
|
4
|
+
import type { Balance, Transaction } from "./enablebanking.ts";
|
|
5
|
+
import type { StoredAccount, StoredSession } from "./store.ts";
|
|
6
|
+
|
|
7
|
+
export interface SimpleTransaction {
|
|
8
|
+
id: string;
|
|
9
|
+
date: string;
|
|
10
|
+
value_date?: string;
|
|
11
|
+
/** Negative = money out. */
|
|
12
|
+
amount: number;
|
|
13
|
+
currency: string;
|
|
14
|
+
counterparty?: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
status: string;
|
|
17
|
+
balance_after?: number;
|
|
18
|
+
merchant_category_code?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function simplifyTransaction(t: Transaction): SimpleTransaction {
|
|
22
|
+
const signed = Number(t.transaction_amount.amount) * (t.credit_debit_indicator === "DBIT" ? -1 : 1);
|
|
23
|
+
const counterparty = (t.credit_debit_indicator === "DBIT" ? t.creditor?.name : t.debtor?.name) || undefined;
|
|
24
|
+
const description = [t.remittance_information?.join(" "), t.bank_transaction_code?.description, t.note].find((s) => s && s.trim()) || undefined;
|
|
25
|
+
return {
|
|
26
|
+
id: t.entry_reference || t.transaction_id || `${t.booking_date}:${signed}:${counterparty ?? ""}`,
|
|
27
|
+
date: t.booking_date || t.value_date || t.transaction_date || "",
|
|
28
|
+
value_date: t.value_date && t.value_date !== t.booking_date ? t.value_date : undefined,
|
|
29
|
+
amount: round2(signed),
|
|
30
|
+
currency: t.transaction_amount.currency,
|
|
31
|
+
counterparty,
|
|
32
|
+
description: description && description !== counterparty ? description : undefined,
|
|
33
|
+
status: t.status,
|
|
34
|
+
balance_after: t.balance_after_transaction ? round2(Number(t.balance_after_transaction.amount)) : undefined,
|
|
35
|
+
merchant_category_code: t.merchant_category_code,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SimpleBalances {
|
|
40
|
+
/** Booked (cleared) balance: CLBD, or ITBD when the bank gives no CLBD. This is the number to use for net worth. */
|
|
41
|
+
booked?: number;
|
|
42
|
+
/** Available to spend, when the bank reports it (XPCD/OTHR "available"). Credit accounts often report the available credit here. */
|
|
43
|
+
available?: number;
|
|
44
|
+
currency?: string;
|
|
45
|
+
reference_date?: string;
|
|
46
|
+
all: Array<{ type: string; name?: string; amount: number; reference_date?: string }>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function simplifyBalances(balances: Balance[]): SimpleBalances {
|
|
50
|
+
const byType = (types: string[]) => balances.find((b) => types.includes(b.balance_type));
|
|
51
|
+
const booked = byType(["CLBD"]) ?? byType(["ITBD"]) ?? byType(["CLAV"]);
|
|
52
|
+
const available = byType(["XPCD"]) ?? balances.find((b) => /avail/i.test(b.name ?? "") || /avail/i.test(b.balance_type));
|
|
53
|
+
return {
|
|
54
|
+
booked: booked ? round2(Number(booked.balance_amount.amount)) : undefined,
|
|
55
|
+
available: available && available !== booked ? round2(Number(available.balance_amount.amount)) : undefined,
|
|
56
|
+
currency: (booked ?? balances[0])?.balance_amount.currency,
|
|
57
|
+
reference_date: (booked ?? balances[0])?.reference_date,
|
|
58
|
+
all: balances.map((b) => ({ type: b.balance_type, name: b.name, amount: round2(Number(b.balance_amount.amount)), reference_date: b.reference_date })),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function describeAccount(a: StoredAccount, s?: StoredSession) {
|
|
63
|
+
return {
|
|
64
|
+
uid: a.uid,
|
|
65
|
+
label: a.label ?? null,
|
|
66
|
+
name: a.name ?? a.product ?? null,
|
|
67
|
+
product: a.product ?? null,
|
|
68
|
+
iban: a.iban ?? a.other_id ?? null,
|
|
69
|
+
currency: a.currency,
|
|
70
|
+
type: a.cash_account_type ?? null,
|
|
71
|
+
bank: s ? `${s.bank.name} (${s.bank.country})` : null,
|
|
72
|
+
consent_valid_until: s?.valid_until ?? null,
|
|
73
|
+
consent_days_left: s ? daysLeft(s.valid_until) : null,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function daysLeft(iso: string): number {
|
|
78
|
+
return Math.floor((Date.parse(iso) - Date.now()) / 86_400_000);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function isoDate(d = new Date()): string {
|
|
82
|
+
return d.toISOString().slice(0, 10);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function daysAgo(n: number): string {
|
|
86
|
+
return isoDate(new Date(Date.now() - n * 86_400_000));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const round2 = (n: number) => Math.round(n * 100) / 100;
|