The problem
Anyone holding money in more than one country ends up with a spreadsheet. A broker in India, an exchange or two, a bank in the UK, a brokerage in the US, each with its own app, its own currency, and its own idea of what a transaction record looks like. The question “what do I actually have” takes twenty minutes and is out of date by the time it is answered.
The products that solve this want the customer to hand over their bank logins to someone else’s servers. That was the trade the client would not make, so the brief was the opposite: a dashboard where the data never leaves the machine it runs on.
The rules set before any code
- Nothing leaves the device. There is no account to create and no server to
create it on. The app binds to
127.0.0.1and refuses a request arriving with any otherHostheader. - Read only, always. Every setup instruction tells you to tick view permissions and nothing else. A key that cannot trade or withdraw is worth far less to an attacker, and the dashboard needs nothing more.
- Credentials are encrypted at rest, not merely stored. A file of API keys sitting in plain text in a home directory is the whole problem, moved.
- More than one person can use it without either of them being able to read the other’s holdings, even the person who installed it.
Those four decided most of what follows, and they are also what I tested against at the end.
What I built
A vault per person, not a database
Each person gets one file. It holds their connections, holdings, transactions and snapshots, encrypted with AES-256-GCM under a key derived from their own master password with scrypt at N=2^17, which costs about half a second and 128 MB per guess on a normal machine. The key exists in memory only while the session is unlocked. The password itself is never written anywhere, so there is no reset: lose it and the data is gone, which the interface says plainly rather than pretending otherwise.
The multi-person part falls out of that design. The first account is the owner and can create a one-time invite code that expires in 48 hours. The invited person chooses their own password, gets their own vault, and the owner still cannot read it, because the owner’s key does not open that file. Sharing a machine does not have to mean sharing a portfolio.
Sessions that assume the worst
The session cookie is HttpOnly and SameSite=Strict, capped at twelve hours,
with an idle lock after thirty minutes. Wrong passwords get a growing delay
after three attempts and a fifteen minute lockout after ten. Requests carrying
Sec-Fetch-Site from another origin are refused outright, which is belt and
braces on top of the cookie policy. The Content-Security-Policy allows scripts
from the app itself and from the bank linking provider’s own widget, so an
injected tag has nowhere to run.
None of that is exotic. It is the set of things that are tedious to add later and nearly free to add first.
Platforms that agree on nothing
Every venue authenticates differently, and none of them agree on what a trade looks like. One signs requests with HMAC-SHA256, another with HMAC-SHA512 over a hashed payload, another with ed25519 over the method, path and timestamp, and one wants a SHA-256 checksum of the key, a request token and the secret. Some return holdings. Some return only trades and expect you to work the holdings out yourself.
So the connectors are the only part of the system that know any of that. Each one is a single file that authenticates in whatever way its venue insists on, and returns the same two shapes: a holding and a transaction. Everything above them, the sync engine, the currency conversion, the charts, has no idea which platform anything came from. Adding another venue is one file and one line in an index.
Some platforms have no usable API at all. Rather than pretend otherwise, those are CSV imports, and they end up in exactly the same two shapes.
An importer that does not punish you for overlapping exports
Bank and broker CSV exports are a mess. The date column is called Date, or Txn Date, or Value Date, or Posting Date, or Completed Date. The amount is one column, or two, or three. So the importer detects columns by a priority ordered list of aliases with a fuzzy fallback, which is also why “Avg Price” wins over a plain “Price” column that might hold a limit price.
The part I am happiest with is smaller than that. Every imported row gets an identifier derived from its own content: a hash of the connection, the raw text of the row, and how many identical rows preceded it. Import January, then import a statement covering December to February, and the January rows land on top of themselves instead of doubling. The alternative is asking people to remember which months they already imported, which they will not.
Cost basis that admits what it does not know
Profit and loss is only meaningful with a purchase price, and on a crypto exchange that price often does not exist: coins arrive by deposit from somewhere else. The engine walks the trade history keeping a running weighted average, reducing cost proportionally on each sale, and where a position has no trades behind it, it reports no cost basis rather than inventing one. A dashboard that quietly assumes zero would show a spectacular and entirely fictional profit.
Watching it work
Syncs stream their progress to the page over Server-Sent Events, so a slow platform shows as a slow platform rather than a frozen screen. It matters more than it sounds: one venue rate limits history to six requests a minute, so a first sync can genuinely take several minutes, and a progress line is the difference between waiting and force quitting.
How I checked it
Writing the isolation rules is the easy half. The half that counts is sitting down afterwards and trying to break them.
- Data isolation. With two accounts on one machine, every API route was called as the second person against the first person’s identifiers, and the vault file was opened directly with the wrong password. Neither route returns anything: the key that would decrypt it does not exist in that session.
- Access control at the server. Every check was exercised with the interface out of the picture, because a restriction that only lives in the front end is not a restriction.
- Session handling. Idle lock, the twelve hour cap, and cookie flags were
confirmed from the browser rather than from the source, along with the
cross-site rejection: a request that arrives with the wrong
Sec-Fetch-Siteor origin is refused. - Abuse limits. Repeated wrong passwords were run until the growing delay and the lockout both fired, then the lockout was waited out to confirm it lifts.
- Re-import safety. Overlapping statements were imported in both orders and the transaction counts compared, which is the test that protects every figure the dashboard displays.
- Read-only in practice. Every outbound call was listed and checked against the venue’s documentation to confirm none of them can place an order or move funds, so the read-only key advice is something the code actually honours.
Stack and why
Node and Express on the server, vanilla JavaScript and Chart.js in the browser,
about four thousand lines in total and no build step. That was deliberate. This
is software someone runs on their own machine, from source, and the fewer
moving parts between npm install and a working dashboard, the more likely it
is that they can read what it does before trusting it with an API key. A
framework would have bought me very little here and cost them that.
What I would do differently
I would write the CSV importer’s column detection against a fixture set from the beginning. It was built one real export at a time, and while it now handles a wide spread of bank and broker formats, the coverage grew by encounter rather than by design. The identifier scheme that makes re-imports safe deserved tests from day one too, because it is the piece that quietly protects every number on the screen.