Engineering, security and assurance

How WealthHorizon is built

If you are going to put client records into a system, you should know how it is made, what it is tested against, and what it does not claim to do. This page sets out the standards the build is held to and the security controls that are in place. At the end it lists the limitations we know about.

121ordered, idempotent database migrations, applied automatically on start
167backend domain services, one per bounded context, holding all the SQL
69feature pages in the application, code-split by route
5,883automated tests across the two suites, all required to pass to merge

1. The stack

Current, supported versions — and a stated reason for anything held back

Both halves of the application are written in TypeScript under strict and compile to ES2022. Dependency updates are taken in batches and verified together, rather than one at a time. Merging them one by one leaves a lockfile that has never been resolved against its siblings, which means it has never been tested.

Languages, runtimes and libraries.
LayerTechnologyVersion
Front-end frameworkReact19.2
Build toolVite8
LanguageTypeScript, ESM, strict: true7.0
Component libraryFluent UI v9 (Fluent 2), Griffel CSS-in-JS9.74
Server runtimeNode.js (LTS)22
Web frameworkExpress5.2
Request validationzod, at every route boundary4.5
Password hashingargon2 (argon2id)0.41
DatabasePostgreSQL16
Database drivernode-postgres8.23
Unit and integration testsVitest (both packages)5
Component testsjsdom + React Testing Library—
End-to-end testsPlaywright, with phone and tablet device profiles—
DeploymentDocker Compose — database, API, web—

Taken, and why each one waited. Three major upgrades were held back, each for a reason we wrote down at the time. All three have since gone in on their own. Express 5 changed the type of route parameters and the routing engine, so it went in route by route. Vite 8 swapped the bundler, which changes what is emitted, so it waited until a build of both projects was part of the verification. The React plugin went in with it. Writing down why a version is not yet taken is part of the standard, and so is taking it once the reason no longer holds.

TypeScript 7 is the one to watch. It is the native compiler port, and it is only a development dependency on paper. The back end builds with tsc, so TypeScript emits the JavaScript that runs in production. We took it on the evidence of a clean type check, a clean emit, both test suites passing, and the emitted server booting and answering its health check. It is named here because it is the first thing to suspect if something odd turns up, and you should know which one that is.

2. How a change is made

Specification first, then a failing test, then the smallest change that passes it

The development standard for this codebase is written down and applies to every change of any size. It is built around proving that something works, rather than looking like progress.

1 · Understand and specify

Restate the goal. Say what the expected behaviour is. Name the edge cases, the ways it could fail and anything that might break for existing users. Do that against the code and tests that are actually there, not an architecture you assumed.

2 · Test plan, then failing tests

A plan covering the normal case, the boundaries, the error cases, what might regress, and anything touching security or data integrity. Write the tests and run them before changing the production code. They have to fail, and for the right reason.

3 · Minimal implementation

The smallest production change that passes the tests, following existing conventions. Broad opportunistic refactoring during a feature or a fix is not permitted.

4 · Refactor under test

Naming, structure and duplication are improved once the tests pass, with behaviour unchanged and the tests re-run afterwards.

5 · Quality gate

Unit tests, affected integration tests, type checks, lint and formatting, and the build. Anything that cannot be run is stated explicitly, along with what should be run by hand.

6 · Documentation in the same change

Documentation is a deliverable, not a follow-up. A change to a page, route, role or feature updates the in-application help, the relevant user guide and the architecture documents in the same change.

Four rules are absolute. Never weaken, skip or delete a test to make a suite pass. Never remove coverage without saying why. Never change public behaviour without a test. Never say the tests pass unless you ran them.

3. Testing and assurance

Seven gates, every one of which blocks a merge

Continuous integration runs on every pull request. A change that fails any of the following cannot be merged.

  • Type check — front end The whole SPA must compile clean under strict with no emit. Type errors are a build failure, not a warning.
  • Type check — back end The same, independently, for the API and its services.
  • SQL against the real schema Every migration is applied to a live PostgreSQL 16 instance to build the real schema, and then every static query in the backend is PREPAREd against it. Preparing checks every table and column reference without running anything, so a query naming a column that does not exist fails the build. A type checker cannot see that kind of mistake, and this catches it before the merge rather than in production.
  • Front-end test suite 3,147 tests across 266 files — pure calculation helpers, formatting and responsive logic, and component behaviour under jsdom.
  • Back-end test suite 3,644 tests across 294 files, run against a migrated and seeded PostgreSQL instance so that the database-backed tests exercise real data rather than a mock. Every test worker is given its own database, so a suite that writes cannot be made to pass or fail by the order it runs in.
  • Browser suite The end-to-end suite runs in Chromium on every pull request. Not nightly, and not when somebody remembers. It drives the application the way a person does: signing in, moving between pages, reading what a screen reader would be given. This is where it catches a regression that the type checker and the unit tests both let through. Mobile and iPad profiles run against the same tests.
  • Dependency audit Both dependency trees are audited on every pull request. A new advisory at or above the set threshold fails the build, so an unpatched dependency stops the merge instead of turning up at the next review.

What the tests are pointed at

The testing concentrates on pure functions with no database behind them, because that is where the arithmetic lives. It is also where a bug is hardest to spot: a wrong answer in financial software is still a number, and it looks like any other. So the suites cover the calculations that produce a figure somebody will act on:

  • Foreign exchange conversion and the historic rate held per transaction.
  • The tax engine, allowances and the capital gains matching rules.
  • Long-term projection, drawdown ordering and the Monte Carlo simulation.
  • Lot tracking, corporate actions and the account valuation rule.
  • Compound interest in the savings estimator, where dividing a rate by twelve instead of taking its twelfth root pays 12.68% a year on a screen that says 12%.
  • Import parsers, balance reconciliation and duplicate detection across every supported file format.
  • Backup encryption, round-tripped and verified including the authentication tag.
  • Role visibility: that an adviser cannot see a client they do not work with, and that the audit log refuses everyone except compliance.

The import readers are also tested against real broker and bank files rather than made-up ones. Complete files from Trading 212, Saxo, Barclays and QIF sources are imported and checked row for row. A row it cannot read is reported, not quietly dropped.

4. Security architecture

The controls, and where each is enforced

Permissions are enforced in two places. In the application, where a refusal can be explained to the person who hit it. And in the database, as constraints and triggers, so a bug or a direct connection cannot get round them.

Implemented controls.
ControlImplementation
Identity and session
Password storageargon2id hashing. Plaintext passwords are never stored or written to a log.
Password policyAt least ten characters with upper case, lower case, a digit and a symbol, and a common-password list rejected.
Session tokensShort-lived signed access tokens, default fifteen minutes, refreshed transparently. Refresh tokens are 32 random bytes; only a SHA-256 hash of one is ever stored.
RevocationSigning out revokes the presented refresh token. A password reset revokes every refresh token for that user, on every device.
Two-factor authenticationOptional per user, time-based one-time password, enforced at sign-in once verified.
Address verificationSingle-use, time-limited e-mail verification where outbound mail is configured; auto-verified where it is not, so an offline installation cannot lock out its own operator.
Account enumerationPassword reset and verification resend always answer success, whether or not the address is registered.
Reset token handling32 random bytes, stored only as a SHA-256 hash, valid for one hour, single use. A weak replacement password is refused before the token is consumed.
Abuse limitingSign-in is limited to ten attempts per account in fifteen minutes. The endpoint checks the limit before it checks the password, and answers 429 with Retry-After. A completed sign-in clears the count, so two mistypes cost you nothing. A correct password still waiting on its second factor does not clear it, so somebody holding just the password cannot reset the counter between guesses at the code. The count is per account rather than per address, because behind a reverse proxy the only address the server can see is the proxy’s, and counting by address would lock out everyone at once. The account is held as a hash in its own namespace, so filling one bucket cannot fill another, and a refusal tells you nothing about whether the account exists. Password-reset requests are limited the same way.
Authorisation and isolation
Tenant isolationEvery record carries its tenant; every query is scoped to it. Staff acting on a client supply an explicit client context, which is checked against an active relationship before any data is returned.
Role-based accessRoles and permissions are data, not code branches: standard user, adviser, compliance manager, hierarchy manager, system administrator. Navigation and routes are both filtered, and the API refuses independently of the interface.
Administrative invariantsThe system always retains at least one active administrator; the first administrator cannot be deleted, demoted or deactivated. Enforced in the service layer and again by database triggers, each with its own error code.
Deletion safetyDeleting users is worked out across the whole selection rather than one row at a time. Otherwise two administrators could each pass a “you are not the last one” check and between them leave none. Every reference to a user is found from the live database catalogue, so a table added later cannot bring the problem back.
Data and integrity
Input validationzod schemas at the route boundary, with database constraints and enumerated types as the backstop. Validation failures return a structured error, never a stack trace.
Backup encryptionAES-256-GCM, with a random salt and initialisation vector for each bundle, and a key derived with PBKDF2-SHA256 over 100,000 iterations. Because the encryption is authenticated, a wrong passphrase fails the check outright instead of producing plausible-looking rubbish.
Backup lifecycleScheduled daily, weekly or monthly, with a retention count that prunes older snapshots and a log of every run.
Audit trailAuthentication, role grants, client record access, planning access, suitability generation and backups are recorded with actor, action, resource, time and address. Writing an audit entry can never fail a request.
Outbound and content
Server-side request forgeryThere is only one place the server fetches an address a user supplied, and that is an institution’s logo. It is checked against a list of what is allowed, not a list of what is dangerous: HTTP and HTTPS only, ports 80 and 443 only, no embedded credentials, and a public host. Addresses are judged numerically, so obfuscated forms of loopback and private ranges are refused, as are link-local and cloud-metadata addresses in every IPv4-mapped form. A bare name with no dot is refused because that is another container. Every redirect hop is re-checked.
Content typeThe bytes decide what the file is, not the header it arrived with. Only known image signatures are stored. SVG is refused, because an SVG can carry script, and a file that can run script served from our own address is cross-site scripting. Stored images are served with no-sniff, a restrictive content-security policy and a same-site resource policy.
SecretsSupplied as environment variables and required at start, so a deployment missing one fails immediately rather than running insecurely. Configuration files are excluded from version control and from the release package; deployment scripts generate strong random values where none is supplied.
Separation of key materialA signing secret is not an encryption key. Brokerage credentials and Open Banking tokens are sealed under their own credential key, not one derived from the token-signing secret. That matters because rotating the signing secret is routine housekeeping, and the first thing anyone does if they suspect a leak. If the two shared a key, rotating it would destroy every stored bank token. Where the credential key is not set, the application refuses to run rather than falling back to a weaker one. A token sealed under the previous key can still be read, and is re-sealed the next time it is written.
HTTP security headersA fixed header set on every API response, applied before any route can answer, and the same set on the pages the web server serves: Strict-Transport-Security; a content security policy of default-src 'none' for the API and default-src 'self' with object-src 'none', base-uri 'none' and form-action 'self' for pages; X-Frame-Options: DENY with frame-ancestors 'none'; X-Content-Type-Options: nosniff; Referrer-Policy: no-referrer, because a wealth application’s URLs carry account identifiers; a permissions policy denying camera, microphone, geolocation, payment and USB; and Cross-Origin-Resource-Policy: same-site. X-Powered-By is removed.
Demonstration dataEvery demonstration account and fixture is behind an explicit flag that defaults to off. A production installation ships with no login accounts at all; the first person to register becomes the first administrator.

5. Data protection

Designed for a UK GDPR and Data Protection Act posture

Data minimisation and purpose limitation

The record a client sees carries only who, what and when. It never shows firm-wide detail. The firm-wide audit trail is a separate view with its own restrictions.

Integrity and confidentiality

argon2id password hashing, hashed session tokens, authenticated backup encryption, tenant isolation, and access invariants enforced at the database as well as in the application.

Accountability

An activity trail covering who opened or changed a client record, including staff. Compliance can see all of it. The client can see the part that relates to them.

Residency and sovereignty

The platform runs on whatever infrastructure the firm chooses. Backups stay inside the database volume. Nothing requires the data to leave the country, or the building.

These are tools that help a firm comply. The firm is still the data controller, and is responsible for its own data protection impact assessment, lawful basis, retention policy and subject access requests.

6. Accessibility and devices

Requirements enforced globally, not decided per screen

  • Target sizeInteractive elements meet a 44×44 pixel minimum on touch devices, applied by a global rule rather than left to each component. A form control counts as one target, not several.
  • Visible focusEvery custom interactive element shows a focus ring on keyboard focus. Removing an outline without providing an equivalent is not permitted.
  • Keyboard operabilityAnything that behaves as a button is operable by keyboard: a tab stop, and Enter and Space firing the same handler. This covers clickable table rows, cards, collapsible headers and metric tiles.
  • SemanticsNavigation marks the current page, collapsible groups announce their state, and icon-only controls carry an accessible label.
  • Reading and zoomInputs use a 16 pixel minimum font size on mobile so that focusing a field does not zoom the page.
  • Physical screenSafe-area insets keep content clear of a notch, a dynamic island and a home indicator. Full-height layouts use dynamic viewport units so content is not clipped behind a browser toolbar that moves.
  • Device envelopeiPhone 13 and newer and equivalent Android phones; iPad Air 2020 and newer in both orientations; desktop and large desktop. Below tablet width the navigation rail becomes a drawer and wide tables scroll within themselves rather than overflowing the page.
  • VerificationEnd-to-end tests run against phone and tablet device profiles as well as desktop.

These implement the WCAG 2.2 success criteria for keyboard operability, visible focus and target size. No third-party conformance audit has been commissioned, and no formal conformance level is claimed.

7. Deployment and ownership

Three containers, no vendor lock-in

The whole system is a PostgreSQL database, a Node API and a single-page application served by nginx. That is all it is, so the same build runs unchanged on your own machine, a network-attached appliance, any public cloud, or a managed container platform.

The API applies the database schema when it starts. You can run it as many times as you like: it records what it has applied and skips those, and retries anything that errored last time. It also understands complex SQL, treating a semicolon as the end of a statement only at the top level, so function bodies and string literals come through intact.

Every build carries a version stamp committed with the source, rather than worked out at deploy time. The number on the screen tells you exactly which build is running.

Licensing and provenance

WealthHorizon is proprietary software owned by Muneris Management Ltd. All rights reserved. It is not open source, and no licence is granted by access to the source; use is governed by a separate written agreement.

Ownership is maintained through three mechanisms:

  • A copyright and proprietary notice in every source file, applied and maintained by tooling.
  • An origin sign-off on every commit, certifying provenance and the right to submit under the stated terms.
  • Intellectual property assignment terms binding every authorised contributor, so that ownership does not fragment.

8. Known limitations

What is not claimed

A security write-up that lists only the good parts is not worth reading. The following are known and documented, and they are here because you should be able to judge the platform accurately.

  • Trusting a forwarded address Sign-in is now rate limited, per account rather than per address. That is because trust proxy is not configured, so behind a reverse proxy the only address the server can see is the proxy’s. A per-address limit is therefore not available, and neither is anything else that needs to tell one caller from another by address. Setting up the forwarded-address chain is a deployment decision, and we treat it as one rather than assuming it.
  • Account identifiers A sort code or account number is stored exactly as the user typed it. Nothing masks or encrypts that field, so it appears in backups, which are themselves encrypted where a passphrase is set. The in-application help and the user guide both say so, and suggest recording only the last few digits. If you need a stronger control, the field would have to be masked on write or held encrypted. That is a change we have not made.
  • Row-level security Tenant isolation is enforced in the service layer, where every query is scoped by tenant. The schema also ships illustrative PostgreSQL row-level-security policies, commented out, as a documented hardening path. They are not enabled today.
  • Open Banking The account-aggregation integration works against a provider’s sandbox and interface. Production use requires the regulatory permissions and provider onboarding that any account information service requires, and that is a firm-level undertaking rather than a configuration setting.
  • Broker connections The Trading 212 engine is built and the boundary is read-only, enforced by an allowlist from which the order endpoint is deliberately absent. Saxo remains at design stage. Reconciliation reports differences; it never applies them.
  • Vulnerability scoring The score is a weighted calculation over recorded signals, and it lists the factors behind it. It tells you who to look at first. It does not decide that a client is vulnerable, and it does not replace the judgement, the conversation or the record of what was done.
  • Planning outputs Projections, forecasts, tax figures and inheritance tax figures are indicative. They depend entirely on what the user entered and on the rates and allowances recorded for the year in question. They are not financial advice, not a personal recommendation and not a guarantee. The product says so on the screens themselves, not only on this page: every tax check carries the line “Guidance, not advice. Check with an adviser before acting.” Inheritance tax in particular turns on facts the application cannot see, such as how a trust is written, whether a holding really qualifies for business relief, and where someone is resident for tax.