amin.mallol
← All work

2022 to present

Cynad

Multi-tenant fund administration platform

Role: Lead engineer. Owned both Next.js apps end to end; co-built the Rails API with the founder.

lines of first-party code
~206klines of first-party code
fund managers live
4fund managers live
RSpec examples
~1,100RSpec examples
commits
~4,680commits

Context

What this is for

Emerging fund managers lose allocations on operations, not performance. The documents exist, but they sit in someone's inbox; the performance history exists, but it has to be reconciled before anyone can see it. To an allocator screening dozens of funds a year, slow is indistinguishable from evasive. Cynad is the platform that removes that latency: the data room is the system of record, not a folder bolted onto one.

Problem

The thing that was actually hard to get right

Every fund manager wants their investors to see their brand, on their domain, with their fund terms, and none of them will accept a shared-looking portal. The naive answer is a fork per client, which is what existed when I started: three forked repos drifting apart, each needing the same security fix applied three times. The real problem was to serve N managers from one codebase without ever leaking one tenant's data, branding or session into another's.

What I built

The surface area

  • A white-label investor portal where investors complete a seven-section KYC profile, subscribe to a fund class as an individual or a corporate entity, invite co-investors, request redemptions, and download their own statements.
  • A manager admin console covering investor onboarding, bulk CSV import of prices, transactions and investors, position consolidation, statement generation, a TipTap letter and news editor, and a CRM with outreach email and click tracking.
  • A Rails 8 JSON:API serving three audiences (investors, fund managers, and fund administrators acting on a manager's behalf) from one model of the fund structure: marketed fund, fund class, series, historical price.
  • A public, crawlable fund catalogue with permissioned materials, so a prospect can diligence a fund at 11pm on a Sunday without emailing anyone.
  • Eleven print-exact PDF documents: subscription forms, redemption notes, monthly and annual statements, consolidation notes.

Interactive

Pick a host. Watch this page become that fund.

One Next.js codebase serves four fund managers. The host decides the tenant, the tenant decides the theme, and nothing is rebuilt to add one. The fourth host is not in the map on purpose.

HOST_TO_ORG

In production this map holds thirteen entries across development, staging and four production domains.

Tiger Hill AdvisorsInvestor portal

Hong Kong long/short equity

My investmentsStatementsFund materials

session cookie: TIGER_HILL_token

GET /client_variables/public_config

{
  "organization": "Tiger Hill Advisors",
  "cookie_prefix": "TIGER_HILL",
  "accent_color": "#c8a24a",
  "accent_ink": "#1a1403",
  "accent_text": "#c8a24a",
  "font_family": "Space Grotesk",
  "hero_tagline": "Hong Kong long/short equity"
}

Those values are now on this document’s root. The accent and display face you are reading came from the object above. accent_text is a separate value because no single hue clears AA contrast on both a near-black and a near-white surface, so the tenant carries one per theme.

The hard parts

Decisions worth defending

Each of these names its trade-off, because a decision without one is a preference.

01

Fail closed, unless closing fails worse

Tenancy is resolved from the Host header against a map of thirteen hosts, and each tenant gets its own cookie namespace (TIGER_HILL_token, JPOOF_token) so one browser can hold separate sessions on separate tenant domains without collision. The investor portal used to fall back to a default tenant for any unrecognised host, which meant a misconfigured DNS alias or a forgotten review app would silently impersonate a real fund and receive its cookies. It now returns 404 for any host not in the map, in production and in development. The admin app, in the same product, deliberately does not do this: its default host is the public marketing site, so 404ing an unlisted host would take cynad.com down over a stray health check with an odd Host header.

The trade-off

Two apps in one product with opposite defaults. That is only defensible because the reason is written down next to each one, and the blast radius of being wrong is different: a leaked tenant session versus a marketing page that stops resolving.

02

Returns that do not lie when money moves mid-period

If you compute a period return from opening and closing portfolio value, an investor who subscribed in March reads as though the fund made them money it did not make. Returns are computed from NAV per share instead, with opening and closing shareholding reconstructed from the transaction ledger and in-period additions and subtractions reported separately. Prices are publish-once: a published price must post-date the series' latest and can never be edited, and a transaction may not pre-date its position's latest. Those invariants are enforced in the model and re-checked cross-row in the bulk CSV path, because the valuation code depends on them holding rather than re-deriving them.

The trade-off

Immutability makes corrections harder: fixing a bad price means a correction transaction, not an UPDATE. That is the right trade for records an auditor will read, and the wrong one for almost anything else.

03

A rate limiter that was bypassable by spelling

Rack::Attack throttles were keyed on the request path. POST /users/sign_in.json and //users/sign_in are the same Rails action and a different string, so either spelling disabled every throttle in the file, not just the sign-in one. The fix resolves the request through the application's own router and matches on controller#action, memoised in the Rack env so the lookup costs 10 to 28 microseconds per request. Two-factor sign-in has a related shape: it answers "second factor required" before authenticating, so a correct password against a 2FA account cannot be used as an oracle by watching the lockout counter.

The trade-off

Matching on resolved routes couples the throttle config to route names, so renaming a controller action silently widens the rule. The spec exercises the real module rather than a test double, so that shows up as a failure.

Ownership

Who wrote what

Commit counts from the repository history, both of my git identities summed. I would rather give you this than a claim you have to take on trust.

SurfaceCommitsMy share
Investor portal1,11896% of commits, owned end to end
Manager admin console83394% of commits, owned end to end
Rails API1,18040% of commits, co-built with the founder

Stack

What it runs on

Frontend

  • Next.js 16 (App Router)
  • React 19
  • Redux Toolkit
  • Formik
  • MUI 7
  • Sass
  • TipTap
  • Motion

Backend

  • Rails 8.1
  • Ruby 4.0
  • PostgreSQL
  • Devise + JWT
  • devise-two-factor (TOTP)
  • Pundit
  • jsonapi-serializer
  • Rack::Attack
  • Active Record Encryption

Infrastructure

  • Heroku
  • AWS S3 (Active Storage)
  • Resend
  • Headless Chrome (ferrum) for PDF
  • GitHub Actions

Testing & quality

  • RSpec (~1,100 examples)
  • Playwright (5 tiers, 68 specs)
  • Jest + React Testing Library
  • axe-core accessibility checks
  • Brakeman
  • bundler-audit
  • RuboCop

Where it is now

Current status

Live and in active development. Four fund managers on their own domains, plus a public fund catalogue. The three per-client forked repositories it replaced have been retired.