Skip to content

Support individual and business entity types - #355

Open
larseidsvoll wants to merge 10 commits into
mainfrom
feat/kyc-entity-types
Open

larseidsvoll wants to merge 10 commits into
mainfrom
feat/kyc-entity-types

Conversation

@larseidsvoll

@larseidsvoll larseidsvoll commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Built in collaboration with @schenkty as part of project Gildor.

Re-opened from the same repo (was previously a fork PR, #354) so CI runs with repository secrets. All review comments from #354 are already addressed in these commits.

What this does

Lets a kyc provider declare which entity types it can verify (individual, business, or both) in its service metadata, and lets a verification request declare which type it is for. This folds business verification (KYB) into the existing kyc service rather than standing up a separate service.

How it works

Both individual and business are redirect flows: the provider returns a webURL to a hosted experience and the client polls for the certificate. The entityType tells the provider which hosted experience to present.

The provider hosts and owns the collection experience for both entity types (the demo-kyc-provider pattern: a hosted form served alongside the anchor API). So the request does not carry entity-specific input details, only which kind of experience to start. KYB detail collection lives in the anchor's hosted form, not in the SDK.

This is intentionally a minimal surface. An earlier draft carried a business-details block in the request and made webURL optional for a synchronous business path; that was dropped once we settled on the anchor hosting the KYB form (so business redirects too, and webURL stays required for both).

Changes

  • resolver.ts: add a named KYCEntityType type and the kycEntityTypes array behind it; add entityTypes to the kyc service metadata, as a map of explicit booleans ({ individual?: boolean; business?: boolean }), mirroring supportedOperations on asset movement; add an optional entityType filter to the kyc search criteria and filter providers by it in lookupKYCServices. A type counts as supported only when its key reads back as an explicit true, the way supportedAffinities is read on FX, so false, a missing key, or malformed metadata all mean unsupported. A provider that declares no entityTypes at all is treated as individual-only.
  • server.ts: add entityTypes to the kyc config (defaults to ['individual']). The author-facing config stays a KYCEntityType[] array and is folded into the boolean map at publish time, so the server always publishes an explicit boolean for every known entity type.
  • common.ts: add entityType to the request (defaults to individual). webURL stays required. KYCEntityType comes from the resolver, so there is one source of truth.
  • client.ts: apply the individual default and pass the entity type into the resolver lookup, on both the create and the status paths.
  • client/index.ts: export the type publicly as KYC.EntityType, following the existing KYC.ClientConfig convention, so consumers do not need a deep import.

Country listing is entity-aware

Resolver.listSupportedKYCCountries() takes an optional entityType, and KYCClient.getSupportedCountries() takes one defaulting to individual. Without this, a business-only provider's countries would be reported as supported individual KYC countries, and a provider that declares no countryCodes would expand the answer to every country regardless of the entity types it serves.

Where the default lives

entityType defaults to individual in the client's resolver lookup. The resolver itself keeps "criteria omitted means no entity filter" for low-level callers, and the HTTP request body is deliberately not normalized: an existing caller's payload is byte-for-byte what it was, so a provider running an older build with strict validation cannot start rejecting it.

The cert schema needs no KYB changes

The existing KYC certificate attribute set (generated from oids.json) is already generic and entity-agnostic, so KYB needs nothing added:

  • EntityType already has an organization arm (SEQUENCE OF GenericOrganizationIdentification) alongside person.
  • OrganizationIdentification already carries bic, lei, and a generic other ({ id, schemeName, issuer }).
  • Document is a single generic container (number, front/back/selfie references, dates, issuing authority) that every document type already reuses.

Business identifiers (EIN, registration number, LEI, DUNS) map to entityType.organization[] via ISO20022 scheme names, and KYB documents map to the generic Document. No per-document cert fields are required.

Reading that attribute back is tracked separately in #439: entityType is currently skipped when decoding sharable attribute references, which is how a consumer would tell a verified business from a verified individual at the end of the flow.

Not in scope

  • Server-side enforcement of entityType. The createVerification handler validates body shape, typia type and signature, but does not check the request's entity type against the configured one. That matches countryCodes, which the handler never reads either: advertise in metadata, filter in the Resolver.
  • Per-country entity granularity. The two filters are ANDed within one service entry, so a single entry cannot express "individual in US, business in CA". A provider with asymmetric coverage publishes two service entries, which the metadata format already supports with no code change.

Back-compat

entityType defaults to individual and entityTypes defaults to ['individual'], so existing providers and callers are unaffected. A provider that predates the field is treated as individual-only, which means an anchor that already does KYB has to republish its metadata before it can be discovered for business.

Tests

  • server.test.ts: a both-types provider advertising and resolving on each type, an assertion that an unsupported type is published as an explicit false rather than an absent key, and a combination matrix over individual-only, business-only, both, undeclared, and a multi-country provider, crossing entity types with country codes.
  • client.test.ts: business createVerification through the client returns a hosted webURL, the entity type reaches the provider, the status poll resolves the same provider, and a business-only provider rejects a caller thstays out of the individual country list.

Verification

  • npx tsc --noEmit: 0 errors - make do-lint: clean
  • make test: the only failures are the pre-existing common.test.ts error round-trip flake, which fails identically on a clean main checkout. The changed files (server, client, resolver tests) all pass.

Note

Medium Risk
Touches KYC provider discovery and verification request shape; defaults limit impact on existing individual-only providers and callers.

Overview
Summary

KYC providers can advertise whether they verify people or businesses, and clients can request the matching flow without a separate KYB service. Both paths stay hosted redirect: provider returns webURL, client polls for the certificate. Omitted fields keep today’s individual-only behavior.

Related Issues

None

Changes Made

  • Providers publish supported entity types (individual, business) in KYC service metadata; lookup can filter on entityType together with country codes
  • Undeclared metadata is treated as individual-only when a caller asks for a specific entity type
  • Verification requests accept optional entityType (defaults to individual); the KYC client passes it into resolver lookup so only capable providers are returned
  • KYC anchor server config accepts entityTypes and exposes them in published metadata

Testing

  • npx tsc --noEmit, make do-lint, make test (per author; server/client/resolver KYC tests cover business redirect and entity-type × country matrix)

Breaking Changes

None

Reviewed by Cursor Bugbot for commit f401697. Configure here.

Let a kyc provider declare which entity types it can verify (individual,
business, or both) and let a verification request declare which type it is
for. Both are redirect flows: the provider returns a webURL to a hosted
experience and the client polls for the certificate. The entity type tells
the provider which hosted experience to present.

The provider hosts and owns the collection experience for both entity types
(see the demo-kyc-provider app pattern: a hosted form served alongside the
anchor API). So the package does not carry entity-specific input details in
the request -- it only carries which kind of experience to start. This keeps
the surface minimal and leaves KYB detail collection to the anchor's hosted
form rather than the SDK.

Changes:
- common.ts: add entityType to the request (defaults to individual). webURL
  stays required (both flows redirect). KYCEntityType is derived from the
  metadata type.
- server.ts: add entityTypes to the kyc config (default ['individual']) and
  publish it in the service metadata.
- client.ts: pass entityType through to the resolver lookup.
- resolver.ts: add entityTypes to the kyc service metadata, add an optional
  entityType filter to the kyc search criteria, and filter providers by it
  in lookupKYCServices (a provider with no declared entityTypes is treated
  as individual-only).
- server.test.ts: business entity test covering metadata advertisement,
  entityType-filtered resolution, and a business createVerification that
  returns a hosted webURL.

The cert schema needs no change for KYB: the existing ISO20022 attribute set
(EntityType.organization, OrganizationIdentification bic/lei/other, the
generic Document container) already represents business identifiers and
documents.

Back-compat: entityType defaults to individual and entityTypes defaults to
['individual'], so existing providers and callers are unaffected.

tsc clean, make do-lint clean. (Pre-existing common.test.ts error round-trip
flake fails identically on clean main -- not introduced here.)
- entityTypes metadata is now a presence map ({ individual?: true,
  business?: true }) so a type cannot be declared twice, per review
- extract a named KYCEntityType type in resolver and reuse it across
  the metadata, search criteria, common, and server modules
- move the business createVerification assertion into the client test
  where the rest of the client flow lives; server test keeps the
  metadata-publish and resolver-lookup coverage
Comment thread src/lib/resolver.ts Outdated
Comment thread src/services/kyc/server.ts
Comment thread src/services/kyc/server.test.ts
Comment thread src/lib/resolver.ts Outdated
@rkeene
rkeene requested a review from Copilot June 5, 2026 03:10
@rkeene rkeene changed the title kyc: support individual and business entity types Support individual and business entity types Jun 5, 2026

This comment was marked as resolved.

Address review on PR #355:

- entityTypes metadata is now a map of explicit booleans (mirroring
  supportedOperations on asset movement), and the resolver reads each key
  via ('boolean') and matches only when explicitly true (mirroring how
  supportedAffinities is read on FX). A false or missing key means
  unsupported, so invalid metadata cannot opt a provider into a type.

- The server publishes an explicit boolean for every known entity type,
  defaulting to { individual: true, business: false }.

- Add an entity-type combination matrix test covering individual-only,
  business-only, both, and undeclared providers against both requested
  types, so the explicit combinations are exercised, not just the implicit
  case.

- Correct the entityTypes doc on the server config: business is a hosted
  redirect flow returning a webURL like individual, not a synchronous
  no-webURL flow.
@larseidsvoll

This comment was marked as resolved.

@larseidsvoll
larseidsvoll requested review from ezraripps and rkeene June 5, 2026 04:44
@sonarqubecloud

This comment was marked as outdated.

Comment thread src/services/kyc/server.test.ts Outdated
Comment thread src/services/kyc/server.test.ts Outdated
Comment thread src/services/kyc/client.test.ts
…try codes

Addresses Srayman's review on PR #355:

- Build each provider/resolver once and reuse it across lookups instead
  of standing up a fresh server and republishing metadata on every
  assertion. The matrix now constructs one provider per distinct config
  (individual-only, business-only, both, undeclared, both over US+CA)
  and queries each resolver repeatedly.
- Drive the assertions from a table of { provider, requested entity
  type, requested country codes, expected } cases looped with a single
  expect, with the case name passed as the assertion message so a
  failure names the exact combination.
- Cross entity types with country codes: a supported entity type in an
  unsupported country is still rejected (the two filters are ANDed), and
  a provider declaring US+CA matches business and individual in CA.
- Dispose every provider server in a finally block.
cursor[bot]

This comment was marked as outdated.

@lucasrosa90

Copy link
Copy Markdown
Contributor

Latest push: fixes and scope trim

Fixes

The individual default was documented but never applied. A caller omitting entityType could be resolved to, and started against, a business-only provider, which broke the back-compat claim this PR makes. The default now lives in the client lookup (getEndpoints). The resolver keeps "omitted means no entity filter" for low-level callers, and the HTTP payload is deliberately not normalized, so we do not start sending a new field on every existing caller's request to providers running an older build with strict validation.

The status poll dropped the entity type. KeetaKYCVerification.getVerificationStatus() forwarded only countryCodes. Once the default was in place, a business verification would have polled as individual and failed to find its own provider. entityType is threaded through now.

listSupportedKYCCountries() was not entity-aware, so a business-only provider's countries were reported as supported individual KYC countries. It now takes an optional entityType, applied before the "no countryCodes means all countries" short-circuit, which would otherwise have let a business-only provider expand the result to every country.

Resolver edge case. 'entityTypes' in service with a present-but-undefined value returned false even for individual, contradicting the "undeclared means individual-only" rule. Both call sites now share one helper.

Two assertions could not fail.

  • 'business' in declaredEntityTypes was a presence check against a map the server publishes with every key, so it passed for an individual-only provider too. It now reads the value via ('boolean').
  • entityType === undefined || 'individual' || 'business' was a tautology under the type. Removed.
  • Added an assertion that an unsupported type is published as an explicit false, and one that entityType actually reaches the provider.

Scope trim

  • Dropped the duplicate KYCEntityType re-export in common.ts; client.ts and server.ts now take the type from resolver.ts, alongside the kycEntityTypes array they already imported from there.
  • Exported the type as KYC.EntityType from the public entry point, following the existing KYC.ClientConfig convention. Consumers previously had no public name for it and would have needed a deep import into lib/resolver.js, which only resolves because the package has no exports map.

Deliberately unchanged

  • No server-side enforcement of entityType. The createVerification handler validates body shape, typia type and signature, but not the request's entity type against the configured one. That matches countryCodes, which the handler never reads either: advertise in metadata, filter in the Resolver. Changing that is a server behavior change affecting both fields, and belongs in its own PR.
  • Per-country entity granularity. The two filters are ANDed within a single service entry, so one entry cannot express "individual in US, business in CA". A provider with asymmetric coverage publishes two service entries, which the metadata format already supports with no code change. Making entityTypes per country would change a shape that becomes a network contract once providers adopt it, so we would rather not pay that before a real provider needs it.
  • Certificate side. Out of scope here. See entityType certificate attribute is skipped when decoding sharable attribute references #439: entityType is skipped when decoding sharable attribute references (certificates.ts:936), which is how a consumer would tell a verified business from a verified individual at the end of the flow. Worth resolving before any consumer depends on it.

Verification

npx tsc --noEmit clean, make do-lint clean, KYC suite green. The common.test.ts error round-trip failures are pre-existing and fail identically with these changes stashed.

@KeetaNetwork KeetaNetwork deleted a comment from cursor Bot Sep 9, 2026
@lucasrosa90

Copy link
Copy Markdown
Contributor

@cursoragent Review it!

@cursor

cursor Bot commented Sep 9, 2026

Copy link
Copy Markdown

Same head as last time (daff593). The client default and status-poll threading look correct. The remaining issues are that the back-compat rules this PR claims are still untested on the wire shapes that exist today, and one JSDoc still describes the opposite of the resolver.

Request changes on the tests and that comment. I would not block on the hosted-redirect design, the boolean map, or the client-side default.

1. “Undeclared” in the matrix is not undeclared. buildProvider(undefined, …) omits entityTypes from server config. The helper then does config.kyc.entityTypes ?? ['individual'] and always publishes { individual: true, business: false }. That is the post-upgrade shape, not live metadata. Providers that have not republished have no entityTypes key, which is the declared === undefined branch in kycServiceSupportsEntityType. The comment above the matrix says the tests above already cover “no entityTypes declared → individual-only”; they do not — they go through serviceMetadata() too. resolver.test.ts already hand-rolls keeta_internal without the field. Lookup with entityType: 'individual' / 'business' there is the cheap lock for the back-compat claim. Sonar’s drop to 93.2% on new code is consistent with that branch sitting unhit.

2. The country-list short-circuit is also untested. The entity filter is applied before “no countryCodes ⇒ every country,” which is the right order. The helper always emits countryCodes (at least []), so 'countryCodes' in service is always true in current tests and that break never runs. A hand-rolled business-only entry with no countryCodes plus getSupportedCountries() (client default individual) is the regression that would otherwise list the world as individual KYC.

3. Split the search-criteria JSDoc. ServiceSearchCriteria['kyc'].entityType says omitted means no filter, then that undeclared metadata is individual-only. Those are different rules. The parenthetical is how a published map is read when a type is requested. Resolver lookup('kyc', { countryCodes }) with no entityType still matches a business-only provider; only getEndpoints applies ?? 'individual'. That mix is how the original routing bug was easy to ship.

4. The request JSDoc overclaims (nit). It says the field defaults to individual when omitted. The client correctly leaves it off the wire. The server then passes the raw body into verificationStarted, so a both-types hook that checks === 'individual' misses classic clients. Default in memory before the hook (does not change the client payload) or say “omitted on the wire; treat as individual.”

Still fine as follow-ups: no server-side enforcement (same as countryCodes), no per-country map inside one entry, no test of two named services.kyc entries on one account, kycProviderURL still only gets {id}, certificate entityType still skipped at certificates.ts (~936) per #439.

The boolean map, ('boolean')-only match, shared helper, KYC.EntityType export, and “don’t inject the field on the wire” choice are right. I’d re-request after the two hand-rolled cases and the criteria JSDoc split.

Open in Web Open in Cursor 

@lucasrosa90
lucasrosa90 requested review from Srayman and sephynox and removed request for lucasrosa90 September 9, 2026 16:00
@sonarqubecloud

Copy link
Copy Markdown

Comment thread src/lib/resolver.ts Outdated
Co-authored-by: ezraripps <19670988+ezraripps@users.noreply.github.com>
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.

6 participants