Screens a name against 31,566 sanctioned entities — OFAC SDN, EU, UN and UK OFSI — entirely inside your browser tab. No server, no signup, and nothing you type is uploaded.
Try it at aml-filter.com — nothing to install. Or run the whole thing locally:
git clone https://github.com/hseshadr/aml-filter && cd aml-filter/frontend && corepack enable && pnpm install && pnpm --filter aml-filter-app devThe recording starts from a warm tab. A first visit has to fetch the list before it can screen anything: 34.3 MB of OFAC SDN data (zstd-compressed in transit) plus a 23 MB embedding model. After that it is cached and keeps working with the network off. The live demo loads OFAC SDN — 19,181 entities — on a cold visit; the EU, UN and UK OFSI lists are opt-in under Settings. Screening time is measured in your own browser and printed with every result, which is the only honest place for that number.
A free, zero-server watchlist-filtering and KYC-review app that runs entirely in your browser — screens your customers against multiple sanctions lists, shows exactly why each one matched, and gives reviewers an auditable workflow.
Live at
aml-filter.com— hosted on Cloudflare Pages, screening entirely in your browser (the signed watchlist bundle is served same-origin). The source is public and MIT-licensed: clone it and the whole thing runs locally with no backend, no key, and no account.docs/DEPLOY.mdcovers self-hosting.
The production path treats mobile memory as a first-class constraint. This status
was verified at commit c0f237078f785dd65027a8530381801ec11c18ac (current main); the
CI badge above always reflects the latest run on main, and per-commit CI + deploy runs
are in the repository's
Actions tab. The exact SHA the live
site is serving is always at aml-filter.com/build.json.
The full pnpm gate is green on Node 22.13.0: 1,215 Vitest unit tests across 127 files
(the five workspace packages), plus all five real-Chromium Playwright e2e lanes. On
mobile, the
workstation serializes engine boot, keeps one list resident at a time, and
immediately cancels and disposes in-flight workers/models on route exit, retry, and
cache clear; high-memory desktops may opt into the faster eager path. Reloads reuse the warm embedder
and replace indexes in an explicit build → swap → dispose order. The retry UI reports
an explicit out-of-memory failure instead of leaving a half-live screening engine
behind. The canonical www.aml-filter.com host returns a 308 to the apex while
preserving path and query; the Pages worker normalizes hostname case and a trailing
DNS dot before making that redirect comparison.
Proof that stays close to the code:
cd frontend
pnpm gate
curl -fsSL https://aml-filter.com/build.jsonThe repository gate covers 395 browser-package tests and 385 application tests, plus
typecheck, lint, build, i18n, KYC, bundle, and Android Chromium + desktop mobile
smoke checks. Playwright WebKit is kept as a local/macOS profile but explicitly skips
when its emulator lacks OPFS/SQLite; a physical iPhone fresh-tab check remains the
owner-gated acceptance step. A production iPhone-sized browser smoke previously
reached /settings with a roughly 26 MB JS heap and no console errors.
The app is still a reference implementation, not legal or regulatory advice, and
embedded WebViews remain outside the release contract.
The supported production baseline is the current and previous desktop releases of Chrome, Edge, Firefox, and Safari 17+. The browser must expose module Workers, OPFS, WebCrypto, and Web Locks in a secure context. The app detects these before boot and shows an explicit unsupported-browser screen. Mobile Safari and Chrome use bounded one-list-at-a-time vector residency so the workstation does not overlap every watchlist with the ONNX/WASM model. Desktop browsers use the same bounded mode when memory is unknown or ≤8 GB; eager residency is reserved for an explicitly reported >8 GB desktop. Embedded WebViews are not part of the release contract.
- What it is — a sanctions-screening + KYC-review workstation that runs 100% in the browser tab: fuzzy name-matching against OFAC/EU/UN/UK watchlists, explained scores, and an auditable review workflow. No server-side database to provision; private KYC state is persisted locally in SQLite-WASM on OPFS.
- Why it works — the heavy lifting is precomputed and signed at publish time (name vectors, content-addressed chunks); the tab only verifies (Ed25519, fail-closed), embeds one query with an in-tab MiniLM, and runs a transparent 5-signal scorer. Customer data lives in SQLite-WASM on OPFS and never leaves the machine.
- Worked example —
cd frontend && pnpm install && pnpm --filter aml-filter-app dev, open/screen, type a sanctioned name: you get a ranked match card with the per-signal score breakdown ("strong name-vector similarity, country match") naming its source list. Load customers at/customers, work the matches at/review. - Core invariants — verify-before-parse on every byte (any signature/hash mismatch
aborts — fetched and cached alike); customer data never leaves the browser; reviews are
append-only during a customer's lifecycle (
match_events) and only re-open when the match materially changes; deleting a customer atomically removes that ledger from the application database too; the TS scorer is parity-locked by frozen golden fixtures;pnpm gate== CI, literally.
Banks and businesses are legally required to check that the people they deal with aren't on government sanctions lists. The hard part isn't looking a name up — it's deciding when two differently-spelled names are the same person. The same person shows up as "Robert", "Bob", and "Rob"; names get spelled a dozen ways across alphabets; one typo can hide a real match.
aml-filter does that fuzzy name-matching, shows its work, and does all of it in a browser tab — no server-side database, no signup, and no remote screening API. Open the app and it:
- Downloads a signed catalog of watchlists — OFAC SDN plus EU, UN, and UK/OFSI — and verifies every one in the tab (Ed25519, fail-closed — any bad signature or hash aborts the load). You choose which lists are active in Settings.
- Holds your customers locally — your whitelist lives in SQLite-WASM on OPFS, inside your own browser. Customer data never leaves your machine. The Customers workspace can import CSV/XLS/XLSX files through a preview-first, duplicate-safe flow and export a tabular XLSX snapshot locally.
- Screens every customer across all enabled lists entirely in the browser, scoring each candidate and handing back a number plus a plain-English reason — "strong name-vector similarity, country match" — tagged with which list it came from. No black box; a reviewer can always see why.
- Gives reviewers an auditable workflow. Each match is reviewed once; a re-screen
only re-flags it (CHANGED — needs re-review) when the underlying data materially
changes. Every disposition is appended to an audit trail you can open per match;
deleting the customer removes its matches and audit history in the same transaction.
SQLite
secure_delete=ONoverwrites deleted cells, and the persistent database uses rollback-journalDELETEmode instead of a reusable WAL. Browser/OS storage may still retain forensic remnants or backups outside the app's control; clearing site data is the authoritative device-level reset. Sensitivity (Strict / Balanced / Lenient) and per-list thresholds are configurable. - Keeps both sides in sync. A new list version re-screens all your customers; editing a customer re-screens that one customer.
Lean and mean: the whole thing is TypeScript that runs client-side. The lists are just signed static files served from any host or CDN, cached durably in your browser so the app works offline — and re-verified fail-closed on every load.
aml-filter is an engineering-portfolio demo, not a compliance product. Do not use it to meet any legal or regulatory obligation. See the Disclaimer and
NOTICE.
You need Node 22.13, pnpm, and internet access on
the first run (it downloads the ~23 MB embedding model once, so the browser never pulls a
model from a CDN afterwards). Nothing else — no backend, no database, no API key, no
account. Everything runs from the frontend/ directory (the pnpm workspace is rooted
there — there is no root package.json).
git clone https://github.com/hseshadr/aml-filter
cd aml-filter/frontend
corepack enable # provides the pinned pnpm version
pnpm install
pnpm --filter aml-filter-app dev # stages the model weights, then starts Vite
# (prints a localhost URL, default http://localhost:5173)The first dev run stages the MiniLM weights, the onnxruntime WASM runtime, and the
landing page's measured stats before Vite starts — a couple of minutes once, ~1 second
on every run after.
Open the printed URL, then:
/screen— the in-tab screening demo. Type a name and watch it match as you type, across every enabled list, with a scored, explained match card that names its source list. (A made-up name returns nothing; a name on a watchlist returns a ranked, reason-by-reason result.)/customers— load your own customers (the local whitelist) and let the app auto-screen them against all enabled lists./review— work the resulting matches: tiered, filterable (All / Needs review / Changed only), with a per-match History drawer. Resolve each one with a reviewer note./settings— choose which lists are active, set sensitivity and per-list thresholds, name the analyst, and clear the durable list cache.
On /customers, Import CSV/XLS/XLSX opens a local preview. Files are parsed in a
short-lived worker, capped at 10 MB and 5,000 rows, validated for required identity
fields, and checked for duplicate references before accepted rows are committed in one
SQLite transaction and re-screened. Export XLSX downloads the customer table
locally with spreadsheet-formula-looking text escaped. This is a tabular customer
transfer, not a full backup of matches, audit events, settings, or the watchlist cache.
The demo catalog (four small fictional lists) is already built, signed, and committed, so
/screen works on a cold clone with no extra steps. It is signed with a throwaway demo
key that is deliberately not the production trust root, so a local dev / preview
server serves that demo verify key at /public.key, and prints one line saying so when it
starts. The deployed site pairs the production bundle with the production pin — see
docs/QUICKSTART.md.
cd frontend
pnpm --filter aml-filter-app build # tsc --noEmit && vite build
# (a prebuild hook stages the MiniLM model
# weights, WASM runtime, and demo stats)
pnpm --filter aml-filter-app preview # serves the minified production build locallyConfig (optional): the app reads build-time settings from
frontend/app/.env(Vite vars). Copyfrontend/app/.env.exampletofrontend/app/.envto point at a hosted bundle (VITE_BUNDLE_BASE_URL) or tune the model-load ceiling (VITE_MODEL_LOAD_TIMEOUT_MS). None are required for the local demo.
One command from frontend/ — CI runs this exact script, so local and CI can't drift:
cd frontend
pnpm gate
# = pnpm -r lint Biome (lint + format)
# pnpm -r typecheck tsc strict, every workspace member
# pnpm -r test Vitest (incl. scoring + tiering parity goldens)
# pnpm -r build production build across the workspace
# + the three Playwright e2e lanes (real Chromium, minified build):
# test:e2e:c1 in-tab screening against the committed signed watchlist
# test:e2e:kyc backend-free KYC journey (onboard → auto-screen → review → resolve)
# test:e2e:bundle signed-bundle delta-sync boot (verify → OPFS → offline reload)There is no backend. The product is three TypeScript packages plus a React SPA, all
under frontend/.
frontend/packages/amlfilter-publisher is the
build-time tool that produces the signed lists. Each watchlist source is a
WatchlistSource adapter (src/sources/) that knows how to fetchRaw() and
parse() one list into a neutral entity shape; the publisher then normalizes →
embeds names with transformers.js in Node (no torch, no Python) → Ed25519-signs.
It registers every list in a catalog.json and packs the whole set — the catalog plus
each list's entities and precomputed name vectors — into a signed, content-addressed
bundle: a signed latest pointer → a content-hashed manifest → deduplicated
chunk/ files the browser delta-syncs and verifies fail-closed.
Entity IDs are namespaced per list (OFAC_SDN:…, EU_CONSOLIDATED:…). The wire format
is documented in docs/WATCHLIST_FORMAT.md.
Four live adapters ship: OFAC SDN, UN, EU, and UK/OFSI. Each must fetch successfully, produce a source-specific plausible entity count, and prove an upstream update time within 90 days. A missing, empty, truncated, stale or future-dated feed is rejected — that data is never signed into a bundle.
Each list is refreshed on its own. These are four independent third parties and they go down separately. When one is unreachable that list is re-served from the copy already published — fetched back through the full trust chain, keeping its own older version, stamped with the age it actually has — while the others refresh normally. Before this, one 500 from the EU webgate also blocked the OFAC refresh, which is the list most people screen against.
A re-served list is never presented as current: catalog.json and each
<slug>/meta.json carry that list's own fetchedAt, sourceUpdatedAt and stale flag,
and the app shows the age beside every list you can enable. A list that can be neither
refreshed nor verified from the published bundle aborts the publish outright — a bundle
never claims coverage it does not have.
Every external feed request is abortable after 45 seconds, so an unresponsive upstream cannot hold a CI runner indefinitely.
# from frontend/
pnpm --filter @amlfilter/publisher run build-demo-bundle # rebuilds the committed signed demo bundle the app loads
pnpm publish-list # the production single-list publish CLI (alias for @amlfilter/publisher publish)The production publish CLI takes flags:
--in <entities.jsonl> --version <v> --key <raw-32-byte-ed25519-seed-file> --out <dir> [--models <dir>]
In CI, .github/workflows/publish-watchlist.yml
attempts a refresh daily (cron 0 6 * * *) and on manual dispatch: it fetches the four
live lists, builds entities.jsonl, signs with the WATCHLIST_SIGNING_KEY repo secret
(a base64-encoded raw 32-byte Ed25519 seed), and emits the signed static files.
Daily is the schedule, not a promise. Upstream feeds go down, and when one does its list keeps the age it has until that feed comes back. So the honest claim is: each list is refreshed daily when its source is reachable, and every list shows you its own last successful update time. Do not read a daily cron as a guarantee that every list is under 24 hours old — read the age the app shows you.
That claim is enforced rather than asserted:
.github/workflows/watchlist-freshness.yml
checks the live published bundle several times a day, verifies the pointer signature,
and opens an issue if any list has gone stale. It exists because the publish cron once
failed for 22 consecutive days without anyone noticing.
frontend/packages/amlfilter-browser is the
in-tab screening engine. It syncs the signed bundle same-origin — the signed latest
pointer → the content-hashed manifest → only the missing chunk/ files — and verifies
every byte fail-closed (Ed25519 + SHA-256 against the pinned public key
frontend/app/public/public.key; any signature or
hash mismatch aborts) → materializes the catalog and each enabled list → decodes the
precomputed name vectors → embeds the query or customer name in-tab
(transformers.js MiniLM, Xenova/all-MiniLM-L6-v2, 384-dim) → runs a brute-force cosine
search → scores each candidate with an explainable weighted scorer.
The MultiListScreeningEngine (engine/multiEngine.ts) uses one shared embedder and
supports two residency modes. Eager mode holds one vector index per enabled list; the
bounded streaming mode keeps metadata for every enabled list but loads, screens, and
disposes one vector index at a time (with a one-list cache and serialized queries). In
either mode it embeds the query once, applies each threshold
(perList[id] ?? query.threshold ?? default), then merges and re-ranks — a strong hit
in any list surfaces. The workstation's deterministic browser memory policy selects
streaming on iPhone/iPad/Android, on devices reporting ≤8 GB, and whenever the browser
cannot report a memory budget; eager residency is reserved for an explicitly reported
8 GB desktop. The persisted list selection and per-list thresholds are unchanged.
The scorer (computeScore / PRESETS: strict / balanced / lenient) sums five
signals — name_vector, name_trigram, alias_match, dob_match, country_match —
and every match carries the per-signal breakdown plus a plain-language summary.
Every scored match is also sealed in the engine at the moment it is produced: a
per-install Ed25519 key signs the score, tier, engine and watchlist versions, and an
inputs hash (engine/matchReceipts.ts, engine/installKey.ts,
engine/scoreReceipt.ts), yielding the signed score receipt the UI verifies — see
Trust model.
Verified bundle bytes are cached durably in the OPFS bundle store (owned by the sync Web Worker, separate from the customer DB), re-verified fail-closed on every load, so the app works offline on a cold network.
Entry point: EngineRuntime.bootstrap() → ScreeningEngine.screen({ name }).
frontend/packages/amlfilter-workstation is
the local-first KYC tier, and frontend/app is the React SPA on top of
it. A SQLite-WASM/OPFS DB Web Worker holds your customers and match history (schema v3:
customers, kyc_matches — now with material_fingerprint + review_state columns —
the append-only match_events audit trail, and settings). The package owns
onboarding, review, tiering, and the bidirectional rescan (rescan.ts:
screenCustomer, rescanAll, syncWatchlist).
Review once, re-review on material change. Each match stores a material_fingerprint
(fingerprint.ts) hashed over the customer's identity fields and the matched entity's
identity fields. On a rescan, an unchanged match keeps its prior disposition and stays
suppressed; a materially-changed one is flagged CHANGED (keeping the prior
disposition) and an event is appended. match_events is insert-only — DETECTED,
DISPOSITIONED, REOPENED, CHANGED, SUPPRESSED — and is the audit trail the History
drawer reads.
React routes:
/— marketing landing/screen— the in-tab screening demo (across enabled lists)/customers— your local customer whitelist (auto-screened)/review— the review board: tiered, View filter (All / Needs review / Changed only), Source column, per-match History drawer/settings— sensitivity + per-list thresholds, list selection, analyst name, clear cache
No login, no API key.
Full write-up and diagrams: docs/ARCHITECTURE.md,
docs/diagrams/.
The lists are distributed as plain signed static files on any host or CDN — no application server in the path. In the browser:
- The engine fetches the signed
latestpointer — the bundle's trust anchor — and verifies its detached Ed25519 signature first. Its signed, repository-wide monotonicsequenceprevents a valid older pointer from rolling back an active bundle. - It then fetches the content-addressed
manifestand eachchunkthe manifest names, verifying every byte (Ed25519 + SHA-256) before materializing the catalog and lists. - Verification is fail-closed against the pinned
public.keybaked into the app build (one trust anchor for the whole bundle — verify-before-parse, top to bottom). Any signature or hash mismatch aborts the load — there is no silent fallback to unverified bytes. The same fail-closed check runs over bytes served from the durable OPFS cache, so a poisoned cache entry can never be trusted.
The signing private key never leaves CI: it lives only as the WATCHLIST_SIGNING_KEY
GitHub Actions secret used by the publish workflow.
The same discipline extends from the engine's input to its output. Every scored match
on /screen carries a signed score receipt: the engine seals
{score, tier, engine version, watchlist version, inputs hash} with an Ed25519 key
generated for this browser install, using the @edgeproc/avow receipt format
(RFC-8785 canonical JSON + Ed25519). What you see is a small status chip beside each
match score — Verified (✓) in the normal case, Not verified when a check fails
(✕ tampered or invalid signature, ⚠ untrusted signer), plus explicit pending and
unavailable states while the trust key loads or when browser storage is blocked. A
receipt-bearing match never renders without a chip, and expanding Score receipt on a
match card shows the full signed envelope (algorithm, signer key, payload hash,
signature, and the sealed subject). The verdict is computed offline, in your browser,
against this install's own key — alter the sealed data in any way and the chip flips to
"Not verified — tampered" (the e2e suite proves exactly that: tampering one byte flips
the verdict).
One honest caveat, in the same breath: the signing key lives in ordinary browser
storage, so a receipt is tamper-evident provenance — this exact score was produced
and not altered afterwards — not proof the machine was uncompromised at signing
time, and not a hardware-backed key. Full design:
docs/ARCHITECTURE.md.
Scoring and tiering correctness are locked by committed golden JSON parity tests:
frontend/packages/amlfilter-browser/src/engine/__fixtures__/scoring/golden.jsonfrontend/packages/amlfilter-workstation/src/__fixtures__/tiering/golden.json
These are now frozen regression snapshots: the TypeScript implementation is the source of truth. (The generators that once produced these goldens were removed in the pivot to a pure-TypeScript app.) Any drift in score, reasons, or tier classification fails the test suite.
aml-filter never bundles or redistributes any official sanctions list as part of the app. The signed lists are built from public sources via per-list adapters — the U.S. Treasury OFAC SDN List, the EU Consolidated list, the UN Consolidated list, and the UK/OFSI consolidated list, all government works in the public domain.
The SDN records are read from the U.S. Commerce Department's Consolidated
Screening List (https://data.trade.gov), keeping only the rows OFAC designates —
Treasury's own export moved behind a bot-challenge WAF in July 2026 and can no longer be
read by a build. That feed publishes names in Latin script only, so each record's
non-Latin aliases (Cyrillic, Arabic, CJK) are added from Treasury's own published
SDN_ADVANCED.XML, obtained through a public mirror and joined on the OFAC entity
number. Two sources with two different trust properties — both are named in
NOTICE, and a deploy that cannot reach the mirror reports reduced alias
coverage rather than implying it has the full set.
The committed demo catalog is built from small sets of fictional entities so the
demo is turnkey from a cold clone. Always screen against the current official lists;
they change often. Attribution and the full data note are in NOTICE.
This README is the canonical index.
docs/QUICKSTART.md— clone → run → screen a name.docs/ARCHITECTURE.md— the in-browser pipeline, the three units, the scoring contract.docs/WATCHLIST_FORMAT.md— the signed catalog + per-list wire format.docs/DEPLOY.md— publishing and hosting the signed catalog + list files.docs/OPERATIONS.md— threat/privacy data flow, numeric SLA and performance budgets, recovery behavior, and exact-deployment proof.docs/MEMORY-ARCHITECTURE.md— resident-memory inventory, bounded residency policy, and the SQLite-vector adoption gates.docs/diagrams/— d2 sources + rendered SVGs.
aml-filter/
├── frontend/ # pnpm workspace (Node 22.13, pnpm, Biome, Vitest, Playwright)
│ ├── .nvmrc # pinned Node version
│ ├── app/ # React + Vite SPA — landing · /screen · /customers · /review · /settings
│ │ ├── public/public.key # pinned Ed25519 verify key
│ │ └── public/bundle/origin/ # committed signed content-addressed demo bundle: latest + manifest/ + chunk/
│ └── packages/
│ ├── amlfilter-publisher/ # @amlfilter/publisher — list adapters → embed → sign → signed content-addressed bundle
│ ├── amlfilter-browser/ # @amlfilter/browser — bundle delta-sync + verify + embed + cosine search + scorer + OPFS cache
│ ├── amlfilter-workstation/ # @amlfilter/workstation — SQLite-WASM/OPFS DB worker + rescan + audit trail
│ └── edgeproc-errors/ # @edgeproc/errors — vendored canonical-errors library (registers the bundle-load taxonomy)
├── .github/workflows/publish-watchlist.yml # daily signed-list publish
├── docs/ # ARCHITECTURE · QUICKSTART · DEPLOY · WATCHLIST_FORMAT · diagrams/
├── LICENSE NOTICE CHANGELOG.md CONTRIBUTING.md
aml-filter is a software demonstration and engineering-portfolio project. It is a
reference implementation, NOT legal advice, NOT a regulatory-compliance product, and
NOT a substitute for a qualified compliance program or a commercial sanctions-screening
vendor. Sanctions screening has real legal consequences; any match — or absence of a
match — must be reviewed by qualified compliance personnel against the official OFAC
source before any decision is made. The operator is solely responsible for their own
regulatory obligations and for any actual filings. The software is provided "as is",
without warranty. See NOTICE and LICENSE.
MIT. Built on the signed-bundle / local-compute concepts from
edge-proc. Third-party data attribution is in
NOTICE.
