Support individual and business entity types#355
Conversation
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
| * returns a `webURL`. A provider that supports both | ||
| * sets both keys to `true`. | ||
| */ | ||
| entityTypes?: { [entityType in KYCEntityType]?: true }; |
There was a problem hiding this comment.
Why couldn't these be false? That is what we do in other places for supportedOperations in asset movement
There was a problem hiding this comment.
Good call. Switched it to a map of explicit booleans in d949bb0, matching what asset movement does for supportedOperations. The resolver now reads each key via ('boolean') and matches only when it's explicitly true, the same way supportedAffinities is read on FX. A false or missing key both mean unsupported.
| readonly kycProviderURL: NonNullable<KeetaAnchorKYCServerConfig['kycProviderURL']>; | ||
| readonly routes: NonNullable<KeetaAnchorKYCServerConfig['routes']>; | ||
| readonly #countryCodes?: CurrencyInfo.Country[] | undefined; | ||
| readonly #entityTypes: KYCEntityType[]; |
There was a problem hiding this comment.
this should be the object, but maybe required keys and defaults to false, or defaults to just { individual: true, buisness: false }
There was a problem hiding this comment.
Done in d949bb0. The server now publishes an explicit boolean for every known entity type, defaulting to { individual: true, business: false }. The author-facing server config stays a friendly KYCEntityType[] array; it's folded into the explicit-boolean object map at publish time.
| expect(individualMatch).toBeDefined(); | ||
| expect(individualMatch !== undefined && 'Test' in individualMatch).toBe(true); | ||
| }); | ||
|
|
There was a problem hiding this comment.
Should probably test the case where the metadata only allows business but individual is requested, as well as other explicit combinations because the existing tests only test the implicit case
There was a problem hiding this comment.
Added in d949bb0: a combination matrix test that stands up individual-only, business-only, both-types, and undeclared providers and asserts the resolver lookup matches/rejects for each requested entity type. Confirms a provider advertising only one type isn't matched for the other, plus the undeclared = individual-only classic behavior.
| * Both are hosted redirect flows: the provider returns a `webURL` to a | ||
| * hosted experience it owns, and the client polls for the certificate. | ||
| */ | ||
| type KYCEntityType = 'individual' | 'business'; |
There was a problem hiding this comment.
Kept them lowercase to match the convention for descriptive categorical unions in the service layer: KYCRedirectStatus ('completed' | 'cancelled' | 'failed'), FX affinity ('from' | 'to'), FX purpose ('quote' | 'estimate'), and StorageObjectVisibility ('public' | 'private') are all lowercase. UPPERCASE is used for wire-protocol enums instead (rail tokens like ACH/KEETA_SEND, notification channels like FCM/RECEIVE_FUNDS). entityType is a categorical descriptor in that first group, so lowercase is consistent. Happy to switch if you'd prefer the wire-enum style here.
There was a problem hiding this comment.
Pull request overview
This PR extends the Anchor KYC service to support both individual (KYC) and business (KYB) entity types by advertising supported entity types in KYC service metadata, allowing requests to specify an entityType, and enabling the resolver/client to filter providers accordingly.
Changes:
- Add
entityTypeto KYC create-verification requests and pass it through client resolution. - Add
entityTypesto KYC provider config/metadata and filter resolver matches by requested entity type. - Add tests covering KYB metadata advertisement and entity-type filtered resolution + client createVerification behavior.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/services/kyc/server.ts | Adds entityTypes to server config and publishes it into KYC service metadata. |
| src/services/kyc/server.test.ts | Adds coverage for business entity type metadata advertisement and resolver filtering. |
| src/services/kyc/common.ts | Introduces request-level entityType and documents the entity-type model. |
| src/services/kyc/client.ts | Passes entityType into resolver lookup when provided. |
| src/services/kyc/client.test.ts | Adds a business createVerification path asserting redirect webURL behavior. |
| src/lib/resolver.ts | Defines KYCEntityType, adds metadata field + search criteria, and filters KYC providers by entity type. |
| * Defaults to `['individual']` (the classic KYC redirect flow). | ||
| * Add `'business'` to advertise Know Your Business (KYB) support, | ||
| * which is performed synchronously from the request's business | ||
| * details with no hosted journey / webURL. A provider that does | ||
| * both lists both: `['individual', 'business']`. |
There was a problem hiding this comment.
Correct, the doc was stale from an earlier design. Fixed in d949bb0. Business KYB is a hosted redirect flow that returns a webURL like the individual flow (the provider hosts the KYB collection form), not a synchronous no-webURL flow, and the request carries no business-details block. The doc now describes both as hosted redirect flows.
| let entityTypes: string[] = ['individual']; | ||
| if ('entityTypes' in checkKYCService) { | ||
| const declared = await checkKYCService.entityTypes?.('object'); | ||
| if (declared !== undefined) { | ||
| entityTypes = Object.keys(declared); | ||
| } | ||
| } |
There was a problem hiding this comment.
Fixed in d949bb0. entityTypes is now a map of explicit booleans, and the filter reads the requested key via ('boolean') and treats only an explicit true as supported. A false, missing, or otherwise non-true value all mean unsupported, so invalid metadata can't accidentally opt a provider into an entity type. This mirrors how supportedAffinities is read on FX.
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.
|
Addressed the latest review in d949bb0 (signed):
tsc, eslint, cspell, and the kyc test suite are green. Re-requesting review. |
|
| * requested entity type. A fresh signer/account per provider keeps the | ||
| * published metadata isolated. | ||
| */ | ||
| async function lookupWith(entityTypes: ('individual' | 'business')[] | undefined, requested: 'individual' | 'business') { |
There was a problem hiding this comment.
Rather than creating and starting a server every time this function is called would it make sense to construct the resolver metadata once for different combinations of individual and business and then test the resolver metadata in isolation to verify lookups return the correct providers?
There was a problem hiding this comment.
Done in 675e34c. Refactored so the provider/resolver is built once per distinct config and the metadata round-trip happens once, then the resolver is queried repeatedly. The matrix now stands up five providers up front (individual-only, business-only, both, undeclared, both over US+CA) and reuses each resolver across every lookup instead of starting a server per assertion. Servers are disposed in a finally block.
| } | ||
|
|
||
| /* Individual-only provider: matches individual, rejects business. */ | ||
| expect(await lookupWith(['individual'], 'individual')).toBe(true); |
There was a problem hiding this comment.
Could these tests be optimized to loop through an array of test cases and expected results?
Also what about combining country codes with different entity types?
There was a problem hiding this comment.
Done in 675e34c. The assertions are now a table of { provider, requested entity type, requested country codes, expected } looped through a single expect, with the case name passed as the assertion message so a failure names the exact combination.
Added country-code x entity-type cases too: a both-types provider scoped to US rejects business and individual when CA is requested (the country and entity filters are ANDed, not ORed), and a provider declaring US+CA matches both entity types in CA. So the two filters are now exercised together rather than in isolation.
| client: client, | ||
| kyc: { | ||
| countryCodes: ['US'], | ||
| entityTypes: ['individual', 'business'], |
There was a problem hiding this comment.
What happens in the case where a provider only supports KYB in specific countries but KYC in other countries? Currently the country codes are separate from the entity types and this structure doesn’t seem to allow for that level of granularity.
Will the server config support handling different countries for KYB and KYC separately?
Will multiple servers need to be created to handle them separately purely to handle the country code list?
Or can a single server handle it via multiple metadata entries separating KYB/KYC and the country code list, but then how would that be handled in the server config.
There was a problem hiding this comment.
Good question, and the granularity is already expressible, just at the level of separate named KYC service entries rather than inside one entry.
The resolver loops every entry under services.kyc and ANDs countryCodes with entityType within each entry (lookupKYCServices in resolver.ts). A single operator account's metadata can carry multiple named KYC entries, so "KYB in some countries, KYC in others" is published as two entries under one account:
services: {
kyc: {
US_KYB: { countryCodes: ['US'], entityTypes: { business: true } },
EU_KYC: { countryCodes: ['DE', 'FR', ...], entityTypes: { individual: true } }
}
}
A lookup for (business, US) matches US_KYB; (individual, DE) matches EU_KYC; (business, DE) matches neither. No separate accounts or servers are required at the protocol level, since one account's metadata map holds both entries, and the resolver already returns the correct subset.
What this PR does not add is per-country entity granularity inside a single entry: within one entry, the declared entityTypes apply across that entry's whole countryCodes list. That is intentional for this PR (entry = one homogeneous country+entity scope), and the multi-entry pattern above covers the mixed case without it.
The one real ergonomic gap is the server config helper: KeetaNetKYCAnchorHTTPServer's kyc block currently produces a single metadata entry via serviceMetadata(), so composing the two-entry example today means either two server instances or hand-merging two serviceMetadata() results into the metadata map. If we want first-class support for one server advertising several country/entity scopes, I would do it as a follow-up: let the server config take an array of scopes and emit multiple named entries, with a test crossing per-country KYB vs KYC. Happy to file that and keep this PR scoped to introducing entity types, or fold it in here if you would rather have it now. Which do you prefer?
…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.



Built in collaboration with @schenkty as part of project Gildor.
What this does
Lets a
kycprovider 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
webURLto a hosted experience and the client polls for the certificate. TheentityTypetells 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
webURLoptional for a synchronous business path; that was dropped once we settled on the anchor hosting the KYB form (so business redirects too, andwebURLstays required for both).Changes
entityTypeto the request (defaults toindividual).webURLstays required.KYCEntityTypeis imported from the resolver for one source of truth.entityTypesto the kyc config (defaults to['individual']) and publish it in the service metadata as a presence map.entityTypethrough to the resolver lookup.KYCEntityTypetype; addentityTypes(a presence map{ individual?: true; business?: true }) to the kyc service metadata; add an optionalentityTypefilter to the kyc search criteria; filter providers by it inlookupKYCServices. A provider with no declaredentityTypesis treated asindividual-only.createVerificationthrough the client returns a hostedwebURL.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:EntityTypealready has anorganizationarm (SEQUENCE OF GenericOrganizationIdentification) alongsideperson.OrganizationIdentificationalready carriesbic,lei, and a genericother({ id, schemeName, issuer }).Documentis 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 genericDocument. No per-document cert fields are required.Back-compat
entityTypedefaults toindividualandentityTypesdefaults to['individual'], so existing providers and callers are unaffected.Verification
npx tsc --noEmit: 0 errorsmake do-lint: cleanmake test: the only failures are the pre-existingcommon.test.tserror round-trip flake, which fails identically on a cleanmaincheckout (2 failed / 549 passed there too). The changed files (server, client, resolver tests) all pass.