Skip to content

feat(aml): iDenfy as an Onfido-alternative branch for Clean Hands#49

Merged
calebtuttle merged 6 commits into
devfrom
feat/idenfy-clean-hands
Jun 3, 2026
Merged

feat(aml): iDenfy as an Onfido-alternative branch for Clean Hands#49
calebtuttle merged 6 commits into
devfrom
feat/idenfy-clean-hands

Conversation

@calebtuttle

Copy link
Copy Markdown
Contributor

Adds iDenfy as a third identity-verification provider for the Clean Hands (AML) flow, as a drop-in replacement for the Onfido gov-id-scan branch. ZK Passport (NFC) is unchanged. Paired with the frontend PR in holonym-foundation/human-id (feat/idenfy-clean-hands).

What changed

  • idenfy is a valid AML provider: added to the AML idvProvider enum (live + sandbox schemas), VALID_IDV_PROVIDERS, and the IAmlChecksSession type; new idenfySessionId field. resolveIdvProvider accepts it with no change.
  • payForSessionV4: creates/reuses an IdenfySession when idvProvider==='idenfy' (mirrors the Onfido branch), returns idenfySessionId, prices at $5 (reuses PAYMENT_SERVICE_CLEAN_HANDS_VERIFICATION; zk-passport stays $3). Reuse is intentionally not flow-scoped — matches Onfido; issuance always re-screens.
  • runSanctionsScreening module (services/aml-sessions/sanctions-screening.ts): faithful extraction of the inline sanctions.io + PEP block from issueCredsV4 — identical query params, PEP-block-by-country, SanctionsResult persistence, whitelist handling, and statement generation. Returns a decision; caller owns session/HTTP. Queries name + DOB only.
  • New issuance endpoint GET /aml-sessions/:_id/credentials/idenfy/v4/:nullifier (+ sandbox): server-polls iDenfy (like the Onfido credentials endpoint). Guards: idvProvider==='idenfy', paid (IN_PROGRESS) state, idempotent recovery (a prior issuance for the same nullifier re-issues instead of double-issuing). Re-confirms the decision live via fetchIdenfyStatus (not the webhook-cached status), pulls name+DOB via extractIdenfyNameDob, rejects empty PII before screening, runs runSanctionsScreening, issues Clean Hands creds in the same shape as Onfido/ZK.
  • extractCreds hardening: now tolerates iDenfy /api/v2/data fields whether flat or nested under data (the real shape is unconfirmed — TODO(U11)); prevents silent empty-PII issuance. Also fixes the gov-id iDenfy flow.

Production paths untouched

The Onfido (issueCredsV4) and ZK Passport (verifyAndIssueZkPassport) sanctions blocks are not migrated onto the new module in this PR (deferred refactor) — zero behavioral diff to production AML issuance.

How to verify

  • bun test src/services/idenfy/ src/services/aml-sessions/ → green (incl. new sanctions-screening.test.ts, extractCreds dual-shape + extractIdenfyNameDob tests)
  • bun check:types → clean
  • The branch defaults to nobody on the frontend; reachable only via ?preferredProvider=idenfy until a one-line flip.

Not in scope / follow-ups

  • Capture a real iDenfy /api/v2/data sandbox response and byte-compare against extractCreds (resolves the TODO(U11) markers).
  • Migrate Onfido + ZK Passport onto runSanctionsScreening (separate refactor PR with characterization tests).

🤖 Generated with Claude Code

…hape

iDenfy's /api/v2/data field placement (flat vs nested under `data`) is
unconfirmed against a real sandbox response. extractCreds read fields flat
while its own type doc described them nested; if the real shape is nested,
every PII field came back empty and the falsy-country guard did not catch it,
allowing empty-PII credential issuance. Read defensively from the nested
object when present, else flat. Applies to the UUID helpers too.

Part of the iDenfy Clean Hands branch (U10).
Add 'idenfy' to the AML idvProvider enum (live + sandbox schemas), the
VALID_IDV_PROVIDERS list, and the IAmlChecksSession type union. Add an
idenfySessionId field to the AML schemas (parallel to onfidoSessionId);
the type interface already declared it. resolveIdvProvider now accepts it
with no further change.

iDenfy Clean Hands branch (U1).
When an AML session's idvProvider is 'idenfy', create or reuse a standalone
IdenfySession (mirroring the Onfido branch), persist session.idenfySessionId,
and return idenfySessionId in the pay response. Pricing reuses the $5
PAYMENT_SERVICE_CLEAN_HANDS_VERIFICATION (zk-passport stays $3). Reuse is not
scoped by flow by design; the issuance handler (U4) always re-runs sanctions.

iDenfy Clean Hands branch (U2).
Canonical sanctions.io + PEP screening extracted faithfully from issueCredsV4's
inline block: identical query params, PEP-block-by-country logic, SanctionsResult
persistence, whitelist handling, and statement generation. Returns a decision
(clear | blocked | declaration-required) instead of writing the session/HTTP
response, so the caller owns lifecycle. Queries name + DOB only (country is not
a query input). Production Onfido/ZK paths are intentionally NOT migrated here
(deferred refactor). Used by the iDenfy issuance handler (U4).

iDenfy Clean Hands branch (U3).
Add GET /aml-sessions/:_id/credentials/idenfy/v4/:nullifier (+ sandbox),
server-polled like the Onfido credentials endpoint. Guards: idvProvider==idenfy,
paid (IN_PROGRESS) state, and idempotent recovery (a prior issuance for the same
nullifier re-issues instead of double-issuing, so repeated polls are safe).
Re-confirms the iDenfy decision live via fetchIdenfyStatus (not the webhook-
cached status), pulls name+DOB via extractIdenfyNameDob, rejects empty PII before
screening, runs the shared runSanctionsScreening, and issues Clean Hands creds in
the same shape as the Onfido/ZK branches. No aggressive rate limit (poll endpoint).

iDenfy Clean Hands branch (U4).
/session-status/v2 only looked in the gov-id SessionModel, so the shared
/idenfy/verify page polling with a Clean Hands (AML) session id got 404
'Session not found'. Fall back to AMLChecksSessionModel when the gov-id lookup
misses; the existing provider=idenfy branch then resolves the AML session's
idenfySessionId the same way it does for gov-id.

iDenfy Clean Hands branch.
@calebtuttle calebtuttle merged commit 78789f0 into dev Jun 3, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant