feat: add store (JSON persistence) and Enable Banking API client
store.js: read/write ./data/store.json; holds config, tokens, agreements, requisitions, account mappings. eb-client.js: RS256 JWT auth (cached, refreshed 60s before expiry), wrappers for /aspsps, /auth, /sessions, /accounts/* endpoints. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ccb8efd72a
commit
2d81b498d9
125
src/eb-client.js
Normal file
125
src/eb-client.js
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* Enable Banking API client.
|
||||||
|
*
|
||||||
|
* Auth: RS256 JWT signed with the app's private key.
|
||||||
|
* Header: { typ: "JWT", alg: "RS256", kid: app_id }
|
||||||
|
* Claims: { iss: "enablebanking.com", aud: "api.enablebanking.com", iat, exp }
|
||||||
|
*
|
||||||
|
* Base URL: https://api.enablebanking.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SignJWT, importPKCS8 } from 'jose';
|
||||||
|
import fetch from 'node-fetch';
|
||||||
|
import { get as getStore } from './store.js';
|
||||||
|
|
||||||
|
const EB_BASE = 'https://api.enablebanking.com';
|
||||||
|
|
||||||
|
// In-memory JWT cache — no need to persist since it's regenerated on startup anyway
|
||||||
|
let _cachedJWT = null;
|
||||||
|
let _cachedJWTExp = 0;
|
||||||
|
|
||||||
|
async function getJWT() {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
// Refresh 60 s before expiry
|
||||||
|
if (_cachedJWT && now < _cachedJWTExp - 60) return _cachedJWT;
|
||||||
|
|
||||||
|
const { config } = getStore();
|
||||||
|
if (!config?.eb_app_id || !config?.eb_private_key) {
|
||||||
|
throw new Error('Enable Banking credentials not configured. Run `npm run setup` first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = await importPKCS8(config.eb_private_key, 'RS256');
|
||||||
|
const exp = now + 3600;
|
||||||
|
_cachedJWT = await new SignJWT({})
|
||||||
|
.setProtectedHeader({ alg: 'RS256', kid: config.eb_app_id })
|
||||||
|
.setIssuer('enablebanking.com')
|
||||||
|
.setAudience('api.enablebanking.com')
|
||||||
|
.setIssuedAt(now)
|
||||||
|
.setExpirationTime(exp)
|
||||||
|
.sign(key);
|
||||||
|
_cachedJWTExp = exp;
|
||||||
|
return _cachedJWT;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(method, path, body) {
|
||||||
|
const jwt = await getJWT();
|
||||||
|
const url = `${EB_BASE}${path}`;
|
||||||
|
const opts = {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${jwt}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||||
|
|
||||||
|
const res = await fetch(url, opts);
|
||||||
|
const text = await res.text();
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try { data = JSON.parse(text); } catch { data = text; }
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = data?.message || data?.error || text;
|
||||||
|
const err = new Error(`EB API ${method} ${path} → ${res.status}: ${msg}`);
|
||||||
|
err.status = res.status;
|
||||||
|
err.body = data;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Institutions ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getASPSPs(country = 'FR') {
|
||||||
|
const data = await request('GET', `/aspsps?country=${encodeURIComponent(country)}&psu_type=personal`);
|
||||||
|
return data.aspsps ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Authorization flow ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start OAuth flow.
|
||||||
|
* Returns { url, authorization_id }
|
||||||
|
*/
|
||||||
|
export async function startAuth({ aspspName, aspspCountry, state, redirectUrl, validUntil }) {
|
||||||
|
return request('POST', '/auth', {
|
||||||
|
access: { valid_until: validUntil },
|
||||||
|
aspsp: { name: aspspName, country: aspspCountry },
|
||||||
|
state,
|
||||||
|
redirect_url: redirectUrl,
|
||||||
|
psu_type: 'personal',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange OAuth code for a session.
|
||||||
|
* Returns session object: { session_id, accounts: [uid, ...], status, ... }
|
||||||
|
*/
|
||||||
|
export async function createSession(code) {
|
||||||
|
return request('POST', '/sessions', { code });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get session status. Returns { session_id, accounts, status, ... } */
|
||||||
|
export async function getSession(sessionId) {
|
||||||
|
return request('GET', `/sessions/${encodeURIComponent(sessionId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Account data ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getAccountDetails(uid) {
|
||||||
|
return request('GET', `/accounts/${encodeURIComponent(uid)}/details`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAccountBalances(uid) {
|
||||||
|
return request('GET', `/accounts/${encodeURIComponent(uid)}/balances`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAccountTransactions(uid, dateFrom, dateTo) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (dateFrom) params.set('date_from', dateFrom);
|
||||||
|
if (dateTo) params.set('date_to', dateTo);
|
||||||
|
const qs = params.toString() ? `?${params}` : '';
|
||||||
|
return request('GET', `/accounts/${encodeURIComponent(uid)}/transactions${qs}`);
|
||||||
|
}
|
||||||
62
src/store.js
Normal file
62
src/store.js
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Persistent JSON store — backed by ./data/store.json (Docker volume mount).
|
||||||
|
*
|
||||||
|
* Schema:
|
||||||
|
* {
|
||||||
|
* config: { eb_app_id, eb_private_key, gc_secret_id, gc_secret_key } | null,
|
||||||
|
* gc_tokens: { [token]: { type: "access"|"refresh", expires_at } },
|
||||||
|
* institutions_cache: [...] | null,
|
||||||
|
* institutions_cache_at: 0,
|
||||||
|
* agreements: { [gc_id]: { id, created, institution_id, max_historical_days,
|
||||||
|
* access_valid_for_days, access_scope } },
|
||||||
|
* requisitions: { [gc_id]: { id, created, status, institution_id, agreements,
|
||||||
|
* accounts, reference, redirect, link,
|
||||||
|
* eb_auth_id, eb_session_id } },
|
||||||
|
* accounts: { [gc_id]: { gc_id, eb_uid, institution_id, iban, currency,
|
||||||
|
* owner_name, name, product, created, last_accessed } }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
||||||
|
import { dirname } from 'path';
|
||||||
|
|
||||||
|
const STORE_PATH = process.env.STORE_PATH || './data/store.json';
|
||||||
|
|
||||||
|
const EMPTY = () => ({
|
||||||
|
config: null,
|
||||||
|
gc_tokens: {},
|
||||||
|
institutions_cache: null,
|
||||||
|
institutions_cache_at: 0,
|
||||||
|
agreements: {},
|
||||||
|
requisitions: {},
|
||||||
|
accounts: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
let _store = null;
|
||||||
|
|
||||||
|
export function load() {
|
||||||
|
if (!existsSync(STORE_PATH)) return EMPTY();
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(STORE_PATH, 'utf8'));
|
||||||
|
} catch {
|
||||||
|
return EMPTY();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function get() {
|
||||||
|
if (!_store) _store = load();
|
||||||
|
return _store;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function save() {
|
||||||
|
const dir = dirname(STORE_PATH);
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(STORE_PATH, JSON.stringify(_store ?? EMPTY(), null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply a mutation function and persist immediately. */
|
||||||
|
export function update(fn) {
|
||||||
|
const s = get();
|
||||||
|
fn(s);
|
||||||
|
save();
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user