From 2d81b498d9f869d9f6d99dee0ce8d789cb5938a2 Mon Sep 17 00:00:00 2001 From: jeanGaston Date: Mon, 13 Apr 2026 10:33:42 +0200 Subject: [PATCH] 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 --- src/eb-client.js | 125 +++++++++++++++++++++++++++++++++++++++++++++++ src/store.js | 62 +++++++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 src/eb-client.js create mode 100644 src/store.js diff --git a/src/eb-client.js b/src/eb-client.js new file mode 100644 index 0000000..42cdce6 --- /dev/null +++ b/src/eb-client.js @@ -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}`); +} diff --git a/src/store.js b/src/store.js new file mode 100644 index 0000000..714e23b --- /dev/null +++ b/src/store.js @@ -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(); +}