From 8be46e8a6ad1c4ccb77401c85886ae3090976da5 Mon Sep 17 00:00:00 2001 From: Lars Eidsvoll Date: Thu, 4 Jun 2026 19:47:22 -0500 Subject: [PATCH 1/9] kyc: support individual and business entity types 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.) --- src/lib/resolver.ts | 44 +++++++++++ src/services/kyc/client.ts | 5 +- src/services/kyc/common.ts | 29 ++++++- src/services/kyc/server.test.ts | 129 ++++++++++++++++++++++++++++++++ src/services/kyc/server.ts | 14 ++++ 5 files changed, 218 insertions(+), 3 deletions(-) diff --git a/src/lib/resolver.ts b/src/lib/resolver.ts index 061ab276..59dfc1c5 100644 --- a/src/lib/resolver.ts +++ b/src/lib/resolver.ts @@ -102,6 +102,21 @@ type ServiceMetadata = { * validate accounts in any country. */ countryCodes?: string[]; + /** + * The entity types which this KYC provider can + * verify. If not specified, the provider is + * assumed to verify `individual` entities only, + * preserving the classic KYC behavior. + * + * - `individual` is the classic KYC redirect flow + * (the provider returns a `webURL` hosted journey). + * - `business` is a Know Your Business (KYB) flow, + * performed synchronously from supplied business + * details with no `webURL`. + * + * A provider that supports both lists both values. + */ + entityTypes?: ('individual' | 'business')[]; /** * The Certificate Authority (CA) Certificate * that this KYC provider uses to sign KYC @@ -347,6 +362,13 @@ type ServiceSearchCriteria = { * of the following countries. */ countryCodes: CountrySearchInput[]; + /** + * Search for a KYC provider which can verify the given entity + * type. If omitted, providers are matched without filtering on + * entity type (a provider with no declared `entityTypes` is + * treated as `individual`-only). + */ + entityType?: 'individual' | 'business'; }; 'assetMovement': { asset?: MovableAssetSearchInput | { from: MovableAssetSearchInput; to: MovableAssetSearchInput; }; @@ -1721,6 +1743,28 @@ class Resolver { } } + /* + * Filter by entity type when requested. A service that + * does not declare `entityTypes` is treated as + * `individual`-only, preserving the classic KYC + * behavior for providers predating this field. + */ + if (criteria.entityType !== undefined) { + let entityTypes: (string | undefined)[] = ['individual']; + if ('entityTypes' in checkKYCService) { + const declared = await checkKYCService.entityTypes?.('array') ?? []; + entityTypes = await Promise.all(declared.map(async function(item) { + return(await item?.('string')); + })); + } + + this.#logger?.debug(`Resolver:${this.id}`, 'Checking entity type:', criteria.entityType, 'against', entityTypes, 'for', checkKYCServiceID); + + if (!entityTypes.includes(criteria.entityType)) { + continue; + } + } + retval[checkKYCServiceID] = assertResolverLookupKYCResult(checkKYCService); } catch (checkKYCServiceError) { this.#logger?.debug(`Resolver:${this.id}`, 'Error checking KYC service', checkKYCServiceID, ':', checkKYCServiceError, ' -- ignoring'); diff --git a/src/services/kyc/client.ts b/src/services/kyc/client.ts index 3c1b170c..97110d03 100644 --- a/src/services/kyc/client.ts +++ b/src/services/kyc/client.ts @@ -132,9 +132,10 @@ const isKeetaKYCAnchorCreateVerificationResponse = createIs(); const isKeetaKYCAnchorGetVerificationStatusResponse = createIs(); -async function getEndpoints(resolver: Resolver, request: Pick): Promise { +async function getEndpoints(resolver: Resolver, request: Pick): Promise { const response = await resolver.lookup('kyc', { - countryCodes: request.countryCodes + countryCodes: request.countryCodes, + ...(request.entityType !== undefined ? { entityType: request.entityType } : {}) }); if (response === undefined) { diff --git a/src/services/kyc/common.ts b/src/services/kyc/common.ts index 106987cb..22266078 100644 --- a/src/services/kyc/common.ts +++ b/src/services/kyc/common.ts @@ -19,6 +19,26 @@ export type OperationNames = keyof Operations; export type KYCRedirectStatus = 'completed' | 'cancelled' | 'failed'; +/** + * The type of legal entity a KYC Anchor verification applies to. + * + * A provider declares which of these it supports via the `entityTypes` + * field of its service metadata. A verification request declares which + * one it is for via {@link KeetaKYCAnchorCreateVerificationRequest.entityType}. + * + * Both flows 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: + * + * - `individual` collects individual KYC details (the classic flow). + * - `business` collects Know Your Business (KYB) details. + * + * The provider hosts and owns the collection experience for both, so the + * request does not carry the entity-specific details -- only which kind of + * experience to start. + */ +export type KYCEntityType = NonNullable[string]['entityTypes']>[number]; + export interface KeetaKYCAnchorCreateVerificationRequest { countryCodes: CountryCodesSearchCriteria; account: ReturnType['publicKeyString']['get']>; @@ -29,6 +49,12 @@ export interface KeetaKYCAnchorCreateVerificationRequest { * {@link KYCRedirectStatus} query parameter indicating the outcome. */ redirectURL?: string; + /** + * The type of entity being verified. Defaults to `individual` when + * omitted, preserving the classic KYC behavior. The provider uses + * this to choose which hosted collection experience to present. + */ + entityType?: KYCEntityType; } type KeetaNetTokenPublicKeyString = ReturnType>['publicKeyString']['get']>; @@ -53,7 +79,8 @@ export type KeetaKYCAnchorCreateVerificationResponse = ({ /** * The URL to the verification service where the user can complete the * verification process. This URL is expected to be a web URL that the - * user can visit to complete the verification. + * user can visit to complete the verification. The provider hosts the + * collection experience for both individual and business entity types. */ webURL: string; } | { diff --git a/src/services/kyc/server.test.ts b/src/services/kyc/server.test.ts index 147e891f..7d0a76f3 100644 --- a/src/services/kyc/server.test.ts +++ b/src/services/kyc/server.test.ts @@ -122,3 +122,132 @@ test('KYC Anchor HTTP Server', async function() { const homeText = await homeResponse.text(); expect(homeText).toBe('Hello World'); }); + +test('KYC Anchor HTTP Server - business (KYB) entity type', async function() { + const signer = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); + const { userClient } = await createNodeAndClient(signer); + + const kycCAAccount = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); + const kycCABuilder = new KeetaNet.lib.Utils.Certificate.CertificateBuilder({ + subjectPublicKey: kycCAAccount, + issuer: kycCAAccount, + serial: 1, + validFrom: new Date(Date.now() - 30_000), + validTo: new Date(Date.now() + 120_000) + }); + const kycCA = await kycCABuilder.build(); + + /* + * A provider that supports BOTH individual and business verification. + * Both are redirect flows to a provider-hosted experience; the entity + * type selects which hosted experience the provider presents. The + * provider owns detail collection, so the request carries no + * entity-specific details. + */ + await using server = new KeetaNetKYCAnchorHTTPServer({ + signer: signer, + ca: kycCA, + client: userClient, + kycProviderURL: 'https://example.com/journey/{id}', + kyc: { + countryCodes: ['US'], + entityTypes: ['individual', 'business'], + verificationStarted: async function(request) { + /* + * The request advertises the entity type; the provider + * would present the matching hosted form. Both return a + * webURL (filled in by the server from kycProviderURL). + */ + expect(request.entityType === undefined || request.entityType === 'individual' || request.entityType === 'business').toBe(true); + return({ + ok: true, + expectedCost: { + min: '0', + max: '0', + token: userClient.baseToken.publicKeyString.get() + } + }); + }, + getCertificates: async function(_ignore_verificationID) { + return([{ certificate: '' }]); + }, + getVerificationStatus: async function() { + return({ status: KYCVerificationStatus.PASSED }); + } + } + }); + + await server.start(); + expect(server.url).toBeDefined(); + + await userClient.setInfo({ + name: 'USER', + description: 'KYC Anchor Test Root (business)', + metadata: Resolver.Metadata.formatMetadata({ + version: 1, + currencyMap: {}, + services: { + kyc: { + Test: await server.serviceMetadata() + } + } + }) + }); + + const resolver = new Resolver({ + client: userClient, + root: userClient.account, + trustedCAs: [] + }); + + /* + * The published metadata advertises both entity types. + */ + const businessMatch = await resolver.lookup('kyc', { + countryCodes: ['US'], + entityType: 'business' + }); + expect(businessMatch).toBeDefined(); + if (businessMatch === undefined || !('Test' in businessMatch)) { + throw(new Error('internal error: business-capable KYC service not found')); + } + const declaredEntityTypes = await businessMatch.Test.entityTypes?.('array'); + expect(declaredEntityTypes).toBeDefined(); + + const individualMatch = await resolver.lookup('kyc', { + countryCodes: ['US'], + entityType: 'individual' + }); + expect(individualMatch).toBeDefined(); + expect(individualMatch !== undefined && 'Test' in individualMatch).toBe(true); + + /* + * Drive a business createVerification directly against the HTTP route. + * Business is a redirect flow like individual, so the server fills in a + * webURL from kycProviderURL and returns it. + */ + const requesterAccount = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); + const signed = await (await import('./common.js')).generateSignedData(requesterAccount); + const createURL = new URL('/api/createVerification', server.url); + const businessResponse = await fetch(createURL, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify({ + request: { + countryCodes: ['US'], + account: requesterAccount.publicKeyString.get(), + signed: signed, + entityType: 'business' + } + }) + }); + + expect(businessResponse.status).toBe(200); + const businessJSON: unknown = await businessResponse.json(); + if (businessJSON === null || typeof businessJSON !== 'object') { + throw(new Error('internal error: business response is not an object')); + } + expect('ok' in businessJSON && businessJSON.ok === true).toBe(true); + expect('webURL' in businessJSON && typeof businessJSON.webURL === 'string').toBe(true); +}); + diff --git a/src/services/kyc/server.ts b/src/services/kyc/server.ts index fe45e3fe..0f9417da 100644 --- a/src/services/kyc/server.ts +++ b/src/services/kyc/server.ts @@ -130,6 +130,17 @@ export interface KeetaAnchorKYCServerConfig extends KeetaAnchorMetadataServerCon * Country codes that this KYC provider can service (default is all country codes) */ countryCodes?: (CurrencyInfo.Country | CurrencyInfo.ISOCountryCode)[]; + + /** + * Entity types that this KYC provider can verify. + * + * 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']`. + */ + entityTypes?: ('individual' | 'business')[]; } /** @@ -168,6 +179,7 @@ export class KeetaNetKYCAnchorHTTPServer extends KeetaAnchorMetadataServer; readonly routes: NonNullable; readonly #countryCodes?: CurrencyInfo.Country[] | undefined; + readonly #entityTypes: ('individual' | 'business')[]; constructor(config: KeetaAnchorKYCServerConfig) { super(config); @@ -179,6 +191,7 @@ export class KeetaNetKYCAnchorHTTPServer extends KeetaAnchorMetadataServer Date: Thu, 4 Jun 2026 20:33:37 -0500 Subject: [PATCH 2/9] kyc: address review on entity-type support - 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 --- src/lib/resolver.ts | 45 +++++++++++++++++++++------------ src/services/kyc/client.test.ts | 21 +++++++++++++++ src/services/kyc/common.ts | 3 ++- src/services/kyc/server.test.ts | 32 ++--------------------- src/services/kyc/server.ts | 11 +++++--- 5 files changed, 61 insertions(+), 51 deletions(-) diff --git a/src/lib/resolver.ts b/src/lib/resolver.ts index 59dfc1c5..adbb8207 100644 --- a/src/lib/resolver.ts +++ b/src/lib/resolver.ts @@ -43,6 +43,17 @@ type CountrySearchCanonical = CurrencyInfo.ISOCountryCode; /* XXX:TODO */ const isCurrencySearchCanonical = createIs(); // #region Global Service Metadata +/** + * The type of legal entity a KYC provider can verify. + * + * - `individual` is the classic Know Your Customer (KYC) flow. + * - `business` is the Know Your Business (KYB) flow. + * + * 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'; + /** * Service Metadata General Structure */ @@ -104,19 +115,20 @@ type ServiceMetadata = { countryCodes?: string[]; /** * The entity types which this KYC provider can - * verify. If not specified, the provider is - * assumed to verify `individual` entities only, - * preserving the classic KYC behavior. + * verify, expressed as a presence map so each + * type can be declared at most once. If omitted, + * the provider is assumed to verify `individual` + * entities only, preserving the classic KYC + * behavior. * - * - `individual` is the classic KYC redirect flow - * (the provider returns a `webURL` hosted journey). - * - `business` is a Know Your Business (KYB) flow, - * performed synchronously from supplied business - * details with no `webURL`. + * - `individual` is the classic KYC redirect flow. + * - `business` is a Know Your Business (KYB) flow. * - * A provider that supports both lists both values. + * Both are hosted redirect flows where the provider + * returns a `webURL`. A provider that supports both + * sets both keys to `true`. */ - entityTypes?: ('individual' | 'business')[]; + entityTypes?: { [entityType in KYCEntityType]?: true }; /** * The Certificate Authority (CA) Certificate * that this KYC provider uses to sign KYC @@ -368,7 +380,7 @@ type ServiceSearchCriteria = { * entity type (a provider with no declared `entityTypes` is * treated as `individual`-only). */ - entityType?: 'individual' | 'business'; + entityType?: KYCEntityType; }; 'assetMovement': { asset?: MovableAssetSearchInput | { from: MovableAssetSearchInput; to: MovableAssetSearchInput; }; @@ -1750,12 +1762,12 @@ class Resolver { * behavior for providers predating this field. */ if (criteria.entityType !== undefined) { - let entityTypes: (string | undefined)[] = ['individual']; + let entityTypes: string[] = ['individual']; if ('entityTypes' in checkKYCService) { - const declared = await checkKYCService.entityTypes?.('array') ?? []; - entityTypes = await Promise.all(declared.map(async function(item) { - return(await item?.('string')); - })); + const declared = await checkKYCService.entityTypes?.('object'); + if (declared !== undefined) { + entityTypes = Object.keys(declared); + } } this.#logger?.debug(`Resolver:${this.id}`, 'Checking entity type:', criteria.entityType, 'against', entityTypes, 'for', checkKYCServiceID); @@ -2778,6 +2790,7 @@ class Resolver { export default Resolver; export type { + KYCEntityType, ServiceMetadata, ServiceMetadataExternalizable, ServiceSearchCriteria, diff --git a/src/services/kyc/client.test.ts b/src/services/kyc/client.test.ts index ef2e4709..c6851ea2 100644 --- a/src/services/kyc/client.test.ts +++ b/src/services/kyc/client.test.ts @@ -77,6 +77,7 @@ test('KYC Anchor Client Test', async function() { client: client, kyc: { countryCodes: ['US'], + entityTypes: ['individual', 'business'], verificationStarted: async function(request) { const id = crypto.randomUUID(); verifications.set(id, request); @@ -225,6 +226,26 @@ test('KYC Anchor Client Test', async function() { expect(providerCountryCodes).toBeDefined(); expect(providerCountryCodes?.[0]?.code).toBe('US'); + /* + * Drive a business (KYB) createVerification through the client. The + * provider advertises both entity types, so requesting `business` + * resolves a provider and starts a redirect flow with a webURL, just + * like individual. The provider hosts the business-details form, so the + * request carries no entity-specific details. + */ + const businessProviders = await kycClient.createVerification({ + countryCodes: ['US'], + account: account, + entityType: 'business' + }); + expect(businessProviders.length).toBeGreaterThan(0); + const businessProvider = businessProviders[0]; + if (businessProvider === undefined) { + throw(new Error('internal error: no business provider available')); + } + const businessVerification = await businessProvider.startVerification(); + expect(businessVerification.webURL).toBeDefined(); + const verification = await provider.startVerification(); loggerBase?.log('Request ID:', verification.id, 'on provider', verification.providerID); diff --git a/src/services/kyc/common.ts b/src/services/kyc/common.ts index 22266078..167e8208 100644 --- a/src/services/kyc/common.ts +++ b/src/services/kyc/common.ts @@ -1,4 +1,5 @@ import type { + KYCEntityType, ServiceMetadata, ServiceSearchCriteria } from '../../lib/resolver.ts'; @@ -37,7 +38,7 @@ export type KYCRedirectStatus = 'completed' | 'cancelled' | 'failed'; * request does not carry the entity-specific details -- only which kind of * experience to start. */ -export type KYCEntityType = NonNullable[string]['entityTypes']>[number]; +export type { KYCEntityType }; export interface KeetaKYCAnchorCreateVerificationRequest { countryCodes: CountryCodesSearchCriteria; diff --git a/src/services/kyc/server.test.ts b/src/services/kyc/server.test.ts index 7d0a76f3..41d0ef42 100644 --- a/src/services/kyc/server.test.ts +++ b/src/services/kyc/server.test.ts @@ -211,8 +211,9 @@ test('KYC Anchor HTTP Server - business (KYB) entity type', async function() { if (businessMatch === undefined || !('Test' in businessMatch)) { throw(new Error('internal error: business-capable KYC service not found')); } - const declaredEntityTypes = await businessMatch.Test.entityTypes?.('array'); + const declaredEntityTypes = await businessMatch.Test.entityTypes?.('object'); expect(declaredEntityTypes).toBeDefined(); + expect(declaredEntityTypes !== undefined && 'business' in declaredEntityTypes).toBe(true); const individualMatch = await resolver.lookup('kyc', { countryCodes: ['US'], @@ -220,34 +221,5 @@ test('KYC Anchor HTTP Server - business (KYB) entity type', async function() { }); expect(individualMatch).toBeDefined(); expect(individualMatch !== undefined && 'Test' in individualMatch).toBe(true); - - /* - * Drive a business createVerification directly against the HTTP route. - * Business is a redirect flow like individual, so the server fills in a - * webURL from kycProviderURL and returns it. - */ - const requesterAccount = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); - const signed = await (await import('./common.js')).generateSignedData(requesterAccount); - const createURL = new URL('/api/createVerification', server.url); - const businessResponse = await fetch(createURL, { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, - body: JSON.stringify({ - request: { - countryCodes: ['US'], - account: requesterAccount.publicKeyString.get(), - signed: signed, - entityType: 'business' - } - }) - }); - - expect(businessResponse.status).toBe(200); - const businessJSON: unknown = await businessResponse.json(); - if (businessJSON === null || typeof businessJSON !== 'object') { - throw(new Error('internal error: business response is not an object')); - } - expect('ok' in businessJSON && businessJSON.ok === true).toBe(true); - expect('webURL' in businessJSON && typeof businessJSON.webURL === 'string').toBe(true); }); diff --git a/src/services/kyc/server.ts b/src/services/kyc/server.ts index 0f9417da..13ef9aa1 100644 --- a/src/services/kyc/server.ts +++ b/src/services/kyc/server.ts @@ -10,7 +10,8 @@ import type { KeetaKYCAnchorCreateVerificationRequest, KeetaKYCAnchorCreateVerificationResponse, KeetaKYCAnchorGetCertificateResponse, - KeetaKYCAnchorGetVerificationStatusResponse + KeetaKYCAnchorGetVerificationStatusResponse, + KYCEntityType } from './common.ts'; import { assertCreateVerificationRequest, @@ -140,7 +141,7 @@ export interface KeetaAnchorKYCServerConfig extends KeetaAnchorMetadataServerCon * details with no hosted journey / webURL. A provider that does * both lists both: `['individual', 'business']`. */ - entityTypes?: ('individual' | 'business')[]; + entityTypes?: KYCEntityType[]; } /** @@ -179,7 +180,7 @@ export class KeetaNetKYCAnchorHTTPServer extends KeetaAnchorMetadataServer; readonly routes: NonNullable; readonly #countryCodes?: CurrencyInfo.Country[] | undefined; - readonly #entityTypes: ('individual' | 'business')[]; + readonly #entityTypes: KYCEntityType[]; constructor(config: KeetaAnchorKYCServerConfig) { super(config); @@ -415,7 +416,9 @@ export class KeetaNetKYCAnchorHTTPServer extends KeetaAnchorMetadataServer Date: Thu, 4 Jun 2026 23:43:39 -0500 Subject: [PATCH 3/9] kyc: explicit-boolean entityTypes map + combination tests 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. --- src/lib/resolver.ts | 42 +++++++++----- src/services/kyc/server.test.ts | 98 +++++++++++++++++++++++++++++++++ src/services/kyc/server.ts | 17 +++--- 3 files changed, 135 insertions(+), 22 deletions(-) diff --git a/src/lib/resolver.ts b/src/lib/resolver.ts index adbb8207..1f44a197 100644 --- a/src/lib/resolver.ts +++ b/src/lib/resolver.ts @@ -52,7 +52,8 @@ const isCurrencySearchCanonical = createIs(); * 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'; +const kycEntityTypes = ['individual', 'business'] as const; +type KYCEntityType = typeof kycEntityTypes[number]; /** * Service Metadata General Structure @@ -115,20 +116,21 @@ type ServiceMetadata = { countryCodes?: string[]; /** * The entity types which this KYC provider can - * verify, expressed as a presence map so each - * type can be declared at most once. If omitted, - * the provider is assumed to verify `individual` - * entities only, preserving the classic KYC - * behavior. + * verify, expressed as a map of explicit booleans + * (mirroring `supportedOperations` on asset + * movement). If omitted, the provider is assumed to + * verify `individual` entities only, preserving the + * classic KYC behavior. * * - `individual` is the classic KYC redirect flow. * - `business` is a Know Your Business (KYB) flow. * * Both are hosted redirect flows where the provider - * returns a `webURL`. A provider that supports both - * sets both keys to `true`. + * returns a `webURL`. A type is supported only when + * its key is explicitly `true`; `false` or a missing + * key both mean unsupported. */ - entityTypes?: { [entityType in KYCEntityType]?: true }; + entityTypes?: { [entityType in KYCEntityType]?: boolean }; /** * The Certificate Authority (CA) Certificate * that this KYC provider uses to sign KYC @@ -1762,17 +1764,26 @@ class Resolver { * behavior for providers predating this field. */ if (criteria.entityType !== undefined) { - let entityTypes: string[] = ['individual']; + /* + * A provider that does not declare `entityTypes` + * is treated as `individual`-only. When it does, + * a type is supported only if its key resolves to + * an explicit `true` (mirroring how + * `supportedAffinities` is read on FX) -- a `false` + * or missing key means unsupported. + */ + let supported: boolean; if ('entityTypes' in checkKYCService) { const declared = await checkKYCService.entityTypes?.('object'); - if (declared !== undefined) { - entityTypes = Object.keys(declared); - } + const declaredValue = declared?.[criteria.entityType]; + supported = declaredValue !== undefined ? await declaredValue('boolean') : false; + } else { + supported = criteria.entityType === 'individual'; } - this.#logger?.debug(`Resolver:${this.id}`, 'Checking entity type:', criteria.entityType, 'against', entityTypes, 'for', checkKYCServiceID); + this.#logger?.debug(`Resolver:${this.id}`, 'Checking entity type:', criteria.entityType, 'supported:', supported, 'for', checkKYCServiceID); - if (!entityTypes.includes(criteria.entityType)) { + if (!supported) { continue; } } @@ -2789,6 +2800,7 @@ class Resolver { } export default Resolver; +export { kycEntityTypes }; export type { KYCEntityType, ServiceMetadata, diff --git a/src/services/kyc/server.test.ts b/src/services/kyc/server.test.ts index 41d0ef42..f212e79f 100644 --- a/src/services/kyc/server.test.ts +++ b/src/services/kyc/server.test.ts @@ -223,3 +223,101 @@ test('KYC Anchor HTTP Server - business (KYB) entity type', async function() { expect(individualMatch !== undefined && 'Test' in individualMatch).toBe(true); }); +/* + * Explicit entity-type combination matrix. The existing tests above cover the + * implicit case (no entityTypes declared -> individual-only) and a + * both-types provider. This covers every explicit declaration against every + * requested entity type, so a provider that only advertises one type is not + * matched for the other. + */ +test('KYC Anchor HTTP Server - entity type combination matrix', async function() { + const kycCAAccount = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); + const kycCABuilder = new KeetaNet.lib.Utils.Certificate.CertificateBuilder({ + subjectPublicKey: kycCAAccount, + issuer: kycCAAccount, + serial: 1, + validFrom: new Date(Date.now() - 30_000), + validTo: new Date(Date.now() + 120_000) + }); + const kycCA = await kycCABuilder.build(); + + /* + * Stand up a provider advertising exactly `entityTypes`, publish its + * metadata, and return a resolver that can be queried for a given + * requested entity type. A fresh signer/account per provider keeps the + * published metadata isolated. + */ + async function lookupWith(entityTypes: ('individual' | 'business')[] | undefined, requested: 'individual' | 'business') { + const providerSigner = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); + const { userClient: providerClient } = await createNodeAndClient(providerSigner); + + await using server = new KeetaNetKYCAnchorHTTPServer({ + signer: providerSigner, + ca: kycCA, + client: providerClient, + kycProviderURL: 'https://example.com/journey/{id}', + kyc: { + countryCodes: ['US'], + ...(entityTypes === undefined ? {} : { entityTypes }), + verificationStarted: async function() { + return({ + ok: true, + expectedCost: { min: '0', max: '0', token: providerClient.baseToken.publicKeyString.get() } + }); + }, + getCertificates: async function() { + return([{ certificate: '' }]); + }, + getVerificationStatus: async function() { + return({ status: KYCVerificationStatus.PASSED }); + } + } + }); + + await server.start(); + + await providerClient.setInfo({ + name: 'USER', + description: 'KYC Anchor Test Root (matrix)', + metadata: Resolver.Metadata.formatMetadata({ + version: 1, + currencyMap: {}, + services: { + kyc: { + Test: await server.serviceMetadata() + } + } + }) + }); + + const resolver = new Resolver({ + client: providerClient, + root: providerClient.account, + trustedCAs: [] + }); + + const match = await resolver.lookup('kyc', { + countryCodes: ['US'], + entityType: requested + }); + + return(match !== undefined && 'Test' in match); + } + + /* Individual-only provider: matches individual, rejects business. */ + expect(await lookupWith(['individual'], 'individual')).toBe(true); + expect(await lookupWith(['individual'], 'business')).toBe(false); + + /* Business-only provider: matches business, rejects individual. */ + expect(await lookupWith(['business'], 'business')).toBe(true); + expect(await lookupWith(['business'], 'individual')).toBe(false); + + /* Both-types provider: matches either. */ + expect(await lookupWith(['individual', 'business'], 'individual')).toBe(true); + expect(await lookupWith(['individual', 'business'], 'business')).toBe(true); + + /* No declaration: treated as individual-only (classic behavior). */ + expect(await lookupWith(undefined, 'individual')).toBe(true); + expect(await lookupWith(undefined, 'business')).toBe(false); +}); + diff --git a/src/services/kyc/server.ts b/src/services/kyc/server.ts index 13ef9aa1..52376f65 100644 --- a/src/services/kyc/server.ts +++ b/src/services/kyc/server.ts @@ -23,6 +23,7 @@ import { import type { Account } from '@keetanetwork/keetanet-client/lib/account.js'; import type * as Signing from '../../lib/utils/signing.js'; import type { ServiceMetadata } from '../../lib/resolver.ts'; +import { kycEntityTypes } from '../../lib/resolver.js'; import { parseSignatureFromURL } from '../../lib/http-server/common.js'; import { KeetaAnchorMetadataServer } from '../../lib/anchor-metadata-server.js'; @@ -135,11 +136,13 @@ export interface KeetaAnchorKYCServerConfig extends KeetaAnchorMetadataServerCon /** * Entity types that this KYC provider can verify. * - * 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']`. + * Defaults to `['individual']` (the classic KYC flow). + * Add `'business'` to advertise Know Your Business (KYB) + * support. Both are hosted redirect flows: the provider + * returns a `webURL` to the hosted collection experience it + * owns (individual KYC or KYB), and the client polls for the + * certificate. A provider that does both lists both: + * `['individual', 'business']`. */ entityTypes?: KYCEntityType[]; } @@ -416,8 +419,8 @@ export class KeetaNetKYCAnchorHTTPServer extends KeetaAnchorMetadataServer { + return([entityType, this.#entityTypes.includes(entityType)]); })), operations }); From 675e34c3b23c53811a5ec64f92cf2c3962f3ea6d Mon Sep 17 00:00:00 2001 From: Lars Eidsvoll Date: Sun, 7 Jun 2026 01:55:52 -0500 Subject: [PATCH 4/9] kyc: table-drive entity-type matrix, build providers once, cross country 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. --- src/services/kyc/server.test.ts | 106 +++++++++++++++++++++++--------- 1 file changed, 78 insertions(+), 28 deletions(-) diff --git a/src/services/kyc/server.test.ts b/src/services/kyc/server.test.ts index f212e79f..594551e4 100644 --- a/src/services/kyc/server.test.ts +++ b/src/services/kyc/server.test.ts @@ -228,7 +228,8 @@ test('KYC Anchor HTTP Server - business (KYB) entity type', async function() { * implicit case (no entityTypes declared -> individual-only) and a * both-types provider. This covers every explicit declaration against every * requested entity type, so a provider that only advertises one type is not - * matched for the other. + * matched for the other, and crosses entity types with country codes so the + * two filters are exercised together rather than in isolation. */ test('KYC Anchor HTTP Server - entity type combination matrix', async function() { const kycCAAccount = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); @@ -242,22 +243,25 @@ test('KYC Anchor HTTP Server - entity type combination matrix', async function() const kycCA = await kycCABuilder.build(); /* - * Stand up a provider advertising exactly `entityTypes`, publish its - * metadata, and return a resolver that can be queried for a given - * requested entity type. A fresh signer/account per provider keeps the - * published metadata isolated. + * Stand up a provider advertising exactly `entityTypes` over + * `countryCodes`, publish its metadata once, and return a resolver that + * can be queried repeatedly. A fresh signer/account per provider keeps + * the published metadata isolated. Building the provider once and + * reusing its resolver across lookups keeps the table-driven cases below + * cheap: one server/metadata round-trip per distinct provider config + * instead of one per assertion. */ - async function lookupWith(entityTypes: ('individual' | 'business')[] | undefined, requested: 'individual' | 'business') { + async function buildProvider(entityTypes: ('individual' | 'business')[] | undefined, countryCodes: ('US' | 'CA')[]) { const providerSigner = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); const { userClient: providerClient } = await createNodeAndClient(providerSigner); - await using server = new KeetaNetKYCAnchorHTTPServer({ + const server = new KeetaNetKYCAnchorHTTPServer({ signer: providerSigner, ca: kycCA, client: providerClient, kycProviderURL: 'https://example.com/journey/{id}', kyc: { - countryCodes: ['US'], + countryCodes: countryCodes, ...(entityTypes === undefined ? {} : { entityTypes }), verificationStarted: async function() { return({ @@ -296,28 +300,74 @@ test('KYC Anchor HTTP Server - entity type combination matrix', async function() trustedCAs: [] }); - const match = await resolver.lookup('kyc', { - countryCodes: ['US'], - entityType: requested - }); - - return(match !== undefined && 'Test' in match); - } - - /* Individual-only provider: matches individual, rejects business. */ - expect(await lookupWith(['individual'], 'individual')).toBe(true); - expect(await lookupWith(['individual'], 'business')).toBe(false); + async function matches(requested: 'individual' | 'business', requestedCountryCodes: ('US' | 'CA')[]) { + const match = await resolver.lookup('kyc', { + countryCodes: requestedCountryCodes, + entityType: requested + }); - /* Business-only provider: matches business, rejects individual. */ - expect(await lookupWith(['business'], 'business')).toBe(true); - expect(await lookupWith(['business'], 'individual')).toBe(false); + return(match !== undefined && 'Test' in match); + } - /* Both-types provider: matches either. */ - expect(await lookupWith(['individual', 'business'], 'individual')).toBe(true); - expect(await lookupWith(['individual', 'business'], 'business')).toBe(true); + return({ server, matches }); + } - /* No declaration: treated as individual-only (classic behavior). */ - expect(await lookupWith(undefined, 'individual')).toBe(true); - expect(await lookupWith(undefined, 'business')).toBe(false); + /* + * One provider per distinct config. Keyed so the table below reads as + * (provider, requested entity type, requested country) -> expected. + */ + const providers = { + individualUS: await buildProvider(['individual'], ['US']), + businessUS: await buildProvider(['business'], ['US']), + bothUS: await buildProvider(['individual', 'business'], ['US']), + undeclaredUS: await buildProvider(undefined, ['US']), + bothUSCA: await buildProvider(['individual', 'business'], ['US', 'CA']) + }; + + try { + const cases: { + name: string; + provider: keyof typeof providers; + requested: 'individual' | 'business'; + countryCodes: ('US' | 'CA')[]; + expected: boolean; + }[] = [ + /* Individual-only provider: matches individual, rejects business. */ + { name: 'individual-only matches individual', provider: 'individualUS', requested: 'individual', countryCodes: ['US'], expected: true }, + { name: 'individual-only rejects business', provider: 'individualUS', requested: 'business', countryCodes: ['US'], expected: false }, + + /* Business-only provider: matches business, rejects individual. */ + { name: 'business-only matches business', provider: 'businessUS', requested: 'business', countryCodes: ['US'], expected: true }, + { name: 'business-only rejects individual', provider: 'businessUS', requested: 'individual', countryCodes: ['US'], expected: false }, + + /* Both-types provider: matches either. */ + { name: 'both matches individual', provider: 'bothUS', requested: 'individual', countryCodes: ['US'], expected: true }, + { name: 'both matches business', provider: 'bothUS', requested: 'business', countryCodes: ['US'], expected: true }, + + /* No declaration: treated as individual-only (classic behavior). */ + { name: 'undeclared matches individual', provider: 'undeclaredUS', requested: 'individual', countryCodes: ['US'], expected: true }, + { name: 'undeclared rejects business', provider: 'undeclaredUS', requested: 'business', countryCodes: ['US'], expected: false }, + + /* + * Country code crossed with entity type. A supported entity type + * in an unsupported country must still be rejected: the two + * filters are ANDed, not ORed. + */ + { name: 'both US-only rejects business in CA', provider: 'bothUS', requested: 'business', countryCodes: ['CA'], expected: false }, + { name: 'both US-only rejects individual in CA', provider: 'bothUS', requested: 'individual', countryCodes: ['CA'], expected: false }, + { name: 'both US+CA matches business in CA', provider: 'bothUSCA', requested: 'business', countryCodes: ['CA'], expected: true }, + { name: 'both US+CA matches individual in CA', provider: 'bothUSCA', requested: 'individual', countryCodes: ['CA'], expected: true }, + { name: 'both US+CA matches business in US', provider: 'bothUSCA', requested: 'business', countryCodes: ['US'], expected: true } + ]; + + for (const testCase of cases) { + const result = await providers[testCase.provider].matches(testCase.requested, testCase.countryCodes); + expect(result, testCase.name).toBe(testCase.expected); + } + } finally { + for (const provider of Object.values(providers)) { + await provider.server[Symbol.asyncDispose](); + } + } }); From daff593d6e09fb23430b5f52e31e2cb438cb665e Mon Sep 17 00:00:00 2001 From: Lucas Rosa Date: Wed, 9 Sep 2026 11:47:02 -0300 Subject: [PATCH 5/9] kyc: apply the individual default and close entity-type gaps --- src/client/index.ts | 2 + src/lib/resolver.ts | 51 ++++++++++--------- src/services/kyc/client.test.ts | 87 +++++++++++++++++++++++++++++++++ src/services/kyc/client.ts | 15 +++--- src/services/kyc/common.ts | 20 -------- src/services/kyc/server.test.ts | 25 ++++++---- src/services/kyc/server.ts | 5 +- 7 files changed, 143 insertions(+), 62 deletions(-) diff --git a/src/client/index.ts b/src/client/index.ts index 41f27104..c248ea86 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,6 +1,7 @@ import type { KeetaKYCAnchorClientConfig } from '../services/kyc/client.ts'; +import type { KYCEntityType } from '../lib/resolver.ts'; import type { KeetaFXAnchorClientConfig } from '../services/fx/client.ts'; @@ -25,6 +26,7 @@ import type { // eslint-disable-next-line @typescript-eslint/no-namespace export namespace KYC { export type ClientConfig = KeetaKYCAnchorClientConfig; + export type EntityType = KYCEntityType; export const Client: typeof KeetaKYCAnchorClient = KeetaKYCAnchorClient; } // TODO: Determine how we want to export the client diff --git a/src/lib/resolver.ts b/src/lib/resolver.ts index 5033ad54..37d05979 100644 --- a/src/lib/resolver.ts +++ b/src/lib/resolver.ts @@ -1645,6 +1645,24 @@ async function verifyServiceEntrySignature(entry: ValuizableObject, logger?: Log return(valid); } +/** + * A service with no usable `entityTypes` declaration is `individual`-only; + * otherwise a type is supported only when its key is explicitly `true`. + */ +async function kycServiceSupportsEntityType(kycService: ValuizableObject, entityType: KYCEntityType): Promise { + const declared = await kycService.entityTypes?.('object'); + if (declared === undefined) { + return(entityType === 'individual'); + } + + const declaredValue = declared[entityType]; + if (declaredValue === undefined) { + return(false); + } + + return(await declaredValue('boolean')); +} + class Resolver { readonly #roots: KeetaNetGenericAccount[]; readonly #trustedCAs: ResolverConfig['trustedCAs']; @@ -1854,29 +1872,8 @@ class Resolver { } } - /* - * Filter by entity type when requested. A service that - * does not declare `entityTypes` is treated as - * `individual`-only, preserving the classic KYC - * behavior for providers predating this field. - */ if (criteria.entityType !== undefined) { - /* - * A provider that does not declare `entityTypes` - * is treated as `individual`-only. When it does, - * a type is supported only if its key resolves to - * an explicit `true` (mirroring how - * `supportedAffinities` is read on FX) -- a `false` - * or missing key means unsupported. - */ - let supported: boolean; - if ('entityTypes' in checkKYCService) { - const declared = await checkKYCService.entityTypes?.('object'); - const declaredValue = declared?.[criteria.entityType]; - supported = declaredValue !== undefined ? await declaredValue('boolean') : false; - } else { - supported = criteria.entityType === 'individual'; - } + const supported = await kycServiceSupportsEntityType(checkKYCService, criteria.entityType); this.#logger?.debug(`Resolver:${this.id}`, 'Checking entity type:', criteria.entityType, 'supported:', supported, 'for', checkKYCServiceID); @@ -2653,7 +2650,11 @@ class Resolver { return(retval); } - async listSupportedKYCCountries(): Promise { + /** + * When `entityType` is omitted, countries are listed across every KYC + * service regardless of the entity types it declares. + */ + async listSupportedKYCCountries(entityType?: KYCEntityType): Promise { const rootMetadata = await this.#getRootMetadata(); /* @@ -2682,6 +2683,10 @@ class Resolver { continue; } + if (entityType !== undefined && !await kycServiceSupportsEntityType(kycService, entityType)) { + continue; + } + /* * If the KYC service does not have a countryCodes * property, then it can validate accounts in any diff --git a/src/services/kyc/client.test.ts b/src/services/kyc/client.test.ts index c6851ea2..c5d2636d 100644 --- a/src/services/kyc/client.test.ts +++ b/src/services/kyc/client.test.ts @@ -245,6 +245,11 @@ test('KYC Anchor Client Test', async function() { } const businessVerification = await businessProvider.startVerification(); expect(businessVerification.webURL).toBeDefined(); + expect(verifications.get(businessVerification.id)?.entityType).toBe('business'); + + /* The status poll must resolve the same provider, so it carries the entity type too. */ + const businessStatus = await businessVerification.getVerificationStatus(); + expect(businessStatus.status).toBe(KYCVerificationStatus.PASSED); const verification = await provider.startVerification(); loggerBase?.log('Request ID:', verification.id, 'on provider', verification.providerID); @@ -327,3 +332,85 @@ test('KYC Anchor Client Test', async function() { }))).join('\n\n'); loggerBase?.log(output); }, 30000); + +test('KYC Anchor Client Test - business-only provider and the individual default', async function() { + const account = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); + const { userClient: client } = await createNodeAndClient(account); + + const kycCAAccount = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); + const kycCABuilder = new KeetaNet.lib.Utils.Certificate.CertificateBuilder({ + subjectPublicKey: kycCAAccount, + issuer: kycCAAccount, + serial: 1, + validFrom: new Date(Date.now() - 30_000), + validTo: new Date(Date.now() + 120_000) + }); + const kycCA = await kycCABuilder.build(); + + await using server = new KeetaNetKYCAnchorHTTPServer({ + signer: account, + ca: kycCA, + client: client, + kycProviderURL: 'https://example.com/journey/{id}', + kyc: { + countryCodes: ['CA'], + entityTypes: ['business'], + verificationStarted: async function() { + return({ + ok: true, + expectedCost: { + min: '0', + max: '0', + token: client.baseToken.publicKeyString.get() + } + }); + }, + getCertificates: async function() { + return([{ certificate: '' }]); + }, + getVerificationStatus: async function() { + return({ status: KYCVerificationStatus.PASSED }); + } + } + }); + + await server.start(); + + await client.setInfo({ + name: 'TEST', + description: 'KYC Anchor Test Root (business-only)', + metadata: KeetaAnchorResolver.Metadata.formatMetadata({ + version: 1, + services: { + kyc: { + Test: await server.serviceMetadata() + } + } + }) + }); + + const kycClient = new KeetaNetAnchor.KYC.Client(client, { root: account }); + + /* Omitting entityType means individual, which this provider does not verify. */ + await expect(kycClient.createVerification({ + countryCodes: ['CA'], + account: account + })).rejects.toThrow('No KYC endpoints found for the given criteria'); + + const businessProviders = await kycClient.createVerification({ + countryCodes: ['CA'], + account: account, + entityType: 'business' + }); + expect(businessProviders.length).toBeGreaterThan(0); + + const individualCountries = (await kycClient.getSupportedCountries()).map(function(country) { + return(country.code); + }); + expect(individualCountries).not.toContain('CA'); + + const businessCountries = (await kycClient.getSupportedCountries('business')).map(function(country) { + return(country.code); + }); + expect(businessCountries).toContain('CA'); +}, 30000); diff --git a/src/services/kyc/client.ts b/src/services/kyc/client.ts index 97110d03..ef6f4af9 100644 --- a/src/services/kyc/client.ts +++ b/src/services/kyc/client.ts @@ -26,7 +26,7 @@ import { addSignatureToURL } from '../../lib/http-server/common.js'; import { SignData, type SignableAccount } from '../../lib/utils/signing.js'; import type { Logger } from '../../lib/log/index.ts'; import type Resolver from '../../lib/resolver.ts'; -import type { ServiceMetadata } from '../../lib/resolver.ts'; +import type { KYCEntityType, ServiceMetadata } from '../../lib/resolver.ts'; import crypto from '../../lib/utils/crypto.js'; import { validateURL } from '../../lib/utils/url.js'; @@ -135,7 +135,7 @@ const isKeetaKYCAnchorGetVerificationStatusResponse = createIs): Promise { const response = await resolver.lookup('kyc', { countryCodes: request.countryCodes, - ...(request.entityType !== undefined ? { entityType: request.entityType } : {}) + entityType: request.entityType ?? 'individual' }); if (response === undefined) { @@ -297,7 +297,8 @@ class KeetaKYCVerification { return(this.client.getVerificationStatus(this.providerID, { id: this.id, account: this.account, - countryCodes: this.request.countryCodes + countryCodes: this.request.countryCodes, + ...(this.request.entityType !== undefined ? { entityType: this.request.entityType } : {}) })); } } @@ -519,12 +520,12 @@ class KeetaKYCAnchorClient { }); } - async getSupportedCountries(): Promise { - return(await this.resolver.listSupportedKYCCountries()); + async getSupportedCountries(entityType: KYCEntityType = 'individual'): Promise { + return(await this.resolver.listSupportedKYCCountries(entityType)); } - async getVerificationStatus(providerID: ProviderID, request: Pick & { id: RequestID; account: SignableAccount; }): Promise { - const endpoints = await getEndpoints(this.resolver, { countryCodes: request.countryCodes }); + async getVerificationStatus(providerID: ProviderID, request: Pick & { id: RequestID; account: SignableAccount; }): Promise { + const endpoints = await getEndpoints(this.resolver, request); if (endpoints === null) { throw(new Error('No KYC endpoints found for the given criteria')); } diff --git a/src/services/kyc/common.ts b/src/services/kyc/common.ts index 167e8208..d2fc5061 100644 --- a/src/services/kyc/common.ts +++ b/src/services/kyc/common.ts @@ -20,26 +20,6 @@ export type OperationNames = keyof Operations; export type KYCRedirectStatus = 'completed' | 'cancelled' | 'failed'; -/** - * The type of legal entity a KYC Anchor verification applies to. - * - * A provider declares which of these it supports via the `entityTypes` - * field of its service metadata. A verification request declares which - * one it is for via {@link KeetaKYCAnchorCreateVerificationRequest.entityType}. - * - * Both flows 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: - * - * - `individual` collects individual KYC details (the classic flow). - * - `business` collects Know Your Business (KYB) details. - * - * The provider hosts and owns the collection experience for both, so the - * request does not carry the entity-specific details -- only which kind of - * experience to start. - */ -export type { KYCEntityType }; - export interface KeetaKYCAnchorCreateVerificationRequest { countryCodes: CountryCodesSearchCriteria; account: ReturnType['publicKeyString']['get']>; diff --git a/src/services/kyc/server.test.ts b/src/services/kyc/server.test.ts index 594551e4..4a17bbaa 100644 --- a/src/services/kyc/server.test.ts +++ b/src/services/kyc/server.test.ts @@ -152,13 +152,7 @@ test('KYC Anchor HTTP Server - business (KYB) entity type', async function() { kyc: { countryCodes: ['US'], entityTypes: ['individual', 'business'], - verificationStarted: async function(request) { - /* - * The request advertises the entity type; the provider - * would present the matching hosted form. Both return a - * webURL (filled in by the server from kycProviderURL). - */ - expect(request.entityType === undefined || request.entityType === 'individual' || request.entityType === 'business').toBe(true); + verificationStarted: async function() { return({ ok: true, expectedCost: { @@ -213,7 +207,8 @@ test('KYC Anchor HTTP Server - business (KYB) entity type', async function() { } const declaredEntityTypes = await businessMatch.Test.entityTypes?.('object'); expect(declaredEntityTypes).toBeDefined(); - expect(declaredEntityTypes !== undefined && 'business' in declaredEntityTypes).toBe(true); + expect(await declaredEntityTypes?.business?.('boolean')).toBe(true); + expect(await declaredEntityTypes?.individual?.('boolean')).toBe(true); const individualMatch = await resolver.lookup('kyc', { countryCodes: ['US'], @@ -309,7 +304,7 @@ test('KYC Anchor HTTP Server - entity type combination matrix', async function() return(match !== undefined && 'Test' in match); } - return({ server, matches }); + return({ server, matches, resolver }); } /* @@ -364,6 +359,18 @@ test('KYC Anchor HTTP Server - entity type combination matrix', async function() const result = await providers[testCase.provider].matches(testCase.requested, testCase.countryCodes); expect(result, testCase.name).toBe(testCase.expected); } + + /* An unsupported type is published as an explicit false, not an absent key. */ + const individualOnly = await providers.individualUS.resolver.lookup('kyc', { + countryCodes: ['US'], + entityType: 'individual' + }); + if (individualOnly === undefined || !('Test' in individualOnly)) { + throw(new Error('internal error: individual-only KYC service not found')); + } + const individualOnlyEntityTypes = await individualOnly.Test.entityTypes?.('object'); + expect(await individualOnlyEntityTypes?.individual?.('boolean')).toBe(true); + expect(await individualOnlyEntityTypes?.business?.('boolean')).toBe(false); } finally { for (const provider of Object.values(providers)) { await provider.server[Symbol.asyncDispose](); diff --git a/src/services/kyc/server.ts b/src/services/kyc/server.ts index d4621e56..072edf7e 100644 --- a/src/services/kyc/server.ts +++ b/src/services/kyc/server.ts @@ -10,8 +10,7 @@ import type { KeetaKYCAnchorCreateVerificationRequest, KeetaKYCAnchorCreateVerificationResponse, KeetaKYCAnchorGetCertificateResponse, - KeetaKYCAnchorGetVerificationStatusResponse, - KYCEntityType + KeetaKYCAnchorGetVerificationStatusResponse } from './common.ts'; import { assertCreateVerificationRequest, @@ -22,7 +21,7 @@ import { } from './common.js'; import type { Account } from '@keetanetwork/keetanet-client/lib/account.js'; import type * as Signing from '../../lib/utils/signing.js'; -import type { ServiceMetadata } from '../../lib/resolver.ts'; +import type { KYCEntityType, ServiceMetadata } from '../../lib/resolver.ts'; import { kycEntityTypes } from '../../lib/resolver.js'; import { parseSignatureFromURL } from '../../lib/http-server/common.js'; import { KeetaAnchorMetadataServer } from '../../lib/anchor-metadata-server.js'; From cf096d976c96b41f72adbf95c6421d45d7629364 Mon Sep 17 00:00:00 2001 From: Lucas Rosa Date: Wed, 9 Sep 2026 11:57:30 -0300 Subject: [PATCH 6/9] fix cpell --- src/services/kyc/server.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/services/kyc/server.test.ts b/src/services/kyc/server.test.ts index 4a17bbaa..c288953d 100644 --- a/src/services/kyc/server.test.ts +++ b/src/services/kyc/server.test.ts @@ -316,7 +316,7 @@ test('KYC Anchor HTTP Server - entity type combination matrix', async function() businessUS: await buildProvider(['business'], ['US']), bothUS: await buildProvider(['individual', 'business'], ['US']), undeclaredUS: await buildProvider(undefined, ['US']), - bothUSCA: await buildProvider(['individual', 'business'], ['US', 'CA']) + bothMultiCountry: await buildProvider(['individual', 'business'], ['US', 'CA']) }; try { @@ -350,9 +350,9 @@ test('KYC Anchor HTTP Server - entity type combination matrix', async function() */ { name: 'both US-only rejects business in CA', provider: 'bothUS', requested: 'business', countryCodes: ['CA'], expected: false }, { name: 'both US-only rejects individual in CA', provider: 'bothUS', requested: 'individual', countryCodes: ['CA'], expected: false }, - { name: 'both US+CA matches business in CA', provider: 'bothUSCA', requested: 'business', countryCodes: ['CA'], expected: true }, - { name: 'both US+CA matches individual in CA', provider: 'bothUSCA', requested: 'individual', countryCodes: ['CA'], expected: true }, - { name: 'both US+CA matches business in US', provider: 'bothUSCA', requested: 'business', countryCodes: ['US'], expected: true } + { name: 'both US+CA matches business in CA', provider: 'bothMultiCountry', requested: 'business', countryCodes: ['CA'], expected: true }, + { name: 'both US+CA matches individual in CA', provider: 'bothMultiCountry', requested: 'individual', countryCodes: ['CA'], expected: true }, + { name: 'both US+CA matches business in US', provider: 'bothMultiCountry', requested: 'business', countryCodes: ['US'], expected: true } ]; for (const testCase of cases) { From 7b1007be66489943efa9034f8cb3c0a127941505 Mon Sep 17 00:00:00 2001 From: Lucas Rosa Date: Wed, 9 Sep 2026 14:16:20 -0300 Subject: [PATCH 7/9] fixes --- src/lib/resolver.test.ts | 17 ++++++++++++++++- src/services/kyc/client.test.ts | 18 ++++++++++++++++++ src/services/kyc/client.ts | 8 ++++++-- src/services/kyc/server.test.ts | 33 ++++++++++++++++++--------------- src/services/kyc/server.ts | 6 +++++- 5 files changed, 63 insertions(+), 19 deletions(-) diff --git a/src/lib/resolver.test.ts b/src/lib/resolver.test.ts index 148db548..d310b1a1 100644 --- a/src/lib/resolver.test.ts +++ b/src/lib/resolver.test.ts @@ -1085,7 +1085,16 @@ test('ignores unparsable anchor metadata', async function() { createVerification: 'https://kyc.bad.com/createVerification' }, countryCodes: ['US'] - } + }, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + broken_kyc_entity_types: { + operations: { + createVerification: 'https://kyc.badentity.com/createVerification' + }, + countryCodes: ['US'], + ca: 'TEST', + entityTypes: 'business' + } as unknown as NonNullable[string] }, assetMovement: { good_amp: { @@ -1198,6 +1207,12 @@ test('ignores unparsable anchor metadata', async function() { name: 'kyc ignores provider missing required ca field', service: 'kyc', criteria: { countryCodes: ['US'] }, + expectedProviderIDs: ['broken_kyc_entity_types', 'good_kyc'] + }, + { + name: 'kyc ignores provider with unparsable entityTypes when filtering by entity type', + service: 'kyc', + criteria: { countryCodes: ['US'], entityType: 'individual' }, expectedProviderIDs: ['good_kyc'] }, { diff --git a/src/services/kyc/client.test.ts b/src/services/kyc/client.test.ts index c5d2636d..f2142d83 100644 --- a/src/services/kyc/client.test.ts +++ b/src/services/kyc/client.test.ts @@ -358,6 +358,7 @@ test('KYC Anchor Client Test - business-only provider and the individual default verificationStarted: async function() { return({ ok: true, + id: crypto.randomUUID(), expectedCost: { min: '0', max: '0', @@ -404,6 +405,23 @@ test('KYC Anchor Client Test - business-only provider and the individual default }); expect(businessProviders.length).toBeGreaterThan(0); + /* + * A caller polling by provider ID must reach a business-only provider even + * without repeating entityType: the ID already picked the provider, so the + * lookup behind these two methods must not filter it back out. + */ + const businessProvider = businessProviders[0]; + if (businessProvider === undefined) { + throw(new Error('internal error: no business provider available')); + } + const businessVerification = await businessProvider.startVerification(); + const directStatus = await kycClient.getVerificationStatus(businessVerification.providerID, { + id: businessVerification.id, + account: account, + countryCodes: ['CA'] + }); + expect(directStatus.status).toBe(KYCVerificationStatus.PASSED); + const individualCountries = (await kycClient.getSupportedCountries()).map(function(country) { return(country.code); }); diff --git a/src/services/kyc/client.ts b/src/services/kyc/client.ts index ef6f4af9..1469a505 100644 --- a/src/services/kyc/client.ts +++ b/src/services/kyc/client.ts @@ -135,7 +135,7 @@ const isKeetaKYCAnchorGetVerificationStatusResponse = createIs): Promise { const response = await resolver.lookup('kyc', { countryCodes: request.countryCodes, - entityType: request.entityType ?? 'individual' + ...(request.entityType !== undefined ? { entityType: request.entityType } : {}) }); if (response === undefined) { @@ -384,7 +384,11 @@ class KeetaKYCAnchorClient { signed: signedData }; - const endpoints = await getEndpoints(this.resolver, signedRequest); + /* Only provider selection defaults to individual; the by-ID lookups below must not filter. */ + const endpoints = await getEndpoints(this.resolver, { + countryCodes: signedRequest.countryCodes, + entityType: signedRequest.entityType ?? 'individual' + }); if (endpoints === null) { throw(new Error('No KYC endpoints found for the given criteria')); } diff --git a/src/services/kyc/server.test.ts b/src/services/kyc/server.test.ts index c288953d..686b740a 100644 --- a/src/services/kyc/server.test.ts +++ b/src/services/kyc/server.test.ts @@ -246,6 +246,8 @@ test('KYC Anchor HTTP Server - entity type combination matrix', async function() * cheap: one server/metadata round-trip per distinct provider config * instead of one per assertion. */ + const startedServers: KeetaNetKYCAnchorHTTPServer[] = []; + async function buildProvider(entityTypes: ('individual' | 'business')[] | undefined, countryCodes: ('US' | 'CA')[]) { const providerSigner = KeetaNet.lib.Account.fromSeed(KeetaNet.lib.Account.generateRandomSeed(), 0); const { userClient: providerClient } = await createNodeAndClient(providerSigner); @@ -274,6 +276,7 @@ test('KYC Anchor HTTP Server - entity type combination matrix', async function() }); await server.start(); + startedServers.push(server); await providerClient.setInfo({ name: 'USER', @@ -304,22 +307,22 @@ test('KYC Anchor HTTP Server - entity type combination matrix', async function() return(match !== undefined && 'Test' in match); } - return({ server, matches, resolver }); + return({ matches, resolver }); } - /* - * One provider per distinct config. Keyed so the table below reads as - * (provider, requested entity type, requested country) -> expected. - */ - const providers = { - individualUS: await buildProvider(['individual'], ['US']), - businessUS: await buildProvider(['business'], ['US']), - bothUS: await buildProvider(['individual', 'business'], ['US']), - undeclaredUS: await buildProvider(undefined, ['US']), - bothMultiCountry: await buildProvider(['individual', 'business'], ['US', 'CA']) - }; - try { + /* + * One provider per distinct config. Keyed so the table below reads as + * (provider, requested entity type, requested country) -> expected. + */ + const providers = { + individualUS: await buildProvider(['individual'], ['US']), + businessUS: await buildProvider(['business'], ['US']), + bothUS: await buildProvider(['individual', 'business'], ['US']), + undeclaredUS: await buildProvider(undefined, ['US']), + bothMultiCountry: await buildProvider(['individual', 'business'], ['US', 'CA']) + }; + const cases: { name: string; provider: keyof typeof providers; @@ -372,8 +375,8 @@ test('KYC Anchor HTTP Server - entity type combination matrix', async function() expect(await individualOnlyEntityTypes?.individual?.('boolean')).toBe(true); expect(await individualOnlyEntityTypes?.business?.('boolean')).toBe(false); } finally { - for (const provider of Object.values(providers)) { - await provider.server[Symbol.asyncDispose](); + for (const startedServer of startedServers) { + await startedServer[Symbol.asyncDispose](); } } }); diff --git a/src/services/kyc/server.ts b/src/services/kyc/server.ts index 072edf7e..3ce58e93 100644 --- a/src/services/kyc/server.ts +++ b/src/services/kyc/server.ts @@ -194,7 +194,11 @@ export class KeetaNetKYCAnchorHTTPServer extends KeetaAnchorMetadataServer Date: Tue, 15 Sep 2026 15:27:29 -0300 Subject: [PATCH 8/9] change storage profile client entity to match kyc entity type --- src/services/storage/clients/profile.test.ts | 37 ++++++++++---------- src/services/storage/clients/profile.ts | 10 +++--- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/src/services/storage/clients/profile.test.ts b/src/services/storage/clients/profile.test.ts index c665362f..7fa9a599 100644 --- a/src/services/storage/clients/profile.test.ts +++ b/src/services/storage/clients/profile.test.ts @@ -36,8 +36,8 @@ function publicPath(account: Account): string { // #region Test Fixtures -const personalProfile: Profile = { - accountType: 'personal', +const individualProfile: Profile = { + accountType: 'individual', displayName: 'Alice', firstName: 'Alice', lastName: 'Smith' @@ -54,24 +54,25 @@ const invalidProfiles: { name: string; profile: unknown }[] = [ { name: 'business missing country', profile: { accountType: 'business', displayName: 'Acme', companyName: 'Acme Corporation Ltd' }}, { name: 'business with invalid country code', profile: { accountType: 'business', displayName: 'Acme', companyName: 'Acme Corporation Ltd', country: 'XX' }}, { name: 'unknown account type', profile: { accountType: 'enterprise', displayName: 'Acme' }}, - { name: 'personal missing name fields', profile: { accountType: 'personal', displayName: 'Alice' }}, - { name: 'personal missing lastName', profile: { accountType: 'personal', displayName: 'Alice', firstName: 'Alice' }} + { name: 'legacy personal account type', profile: { accountType: 'personal', displayName: 'Alice', firstName: 'Alice', lastName: 'Smith' }}, + { name: 'individual missing name fields', profile: { accountType: 'individual', displayName: 'Alice' }}, + { name: 'individual missing lastName', profile: { accountType: 'individual', displayName: 'Alice', firstName: 'Alice' }} ]; const privateFieldCases: { name: string; profile: Profile; expected: { [key: string]: unknown }}[] = [ - { name: 'personal', profile: personalProfile, expected: { firstName: 'Alice', lastName: 'Smith' }}, + { name: 'individual', profile: individualProfile, expected: { firstName: 'Alice', lastName: 'Smith' }}, { name: 'business', profile: businessProfile, expected: { companyName: 'Acme Corporation Ltd', country: 'US' }} ]; // #endregion describe('Storage Profile Client', function() { - test('set and get a personal profile', function() { + test('set and get an individual profile', function() { return(withProfile(randomSeed(), async function({ profileClient }) { - await profileClient.set(personalProfile); + await profileClient.set(individualProfile); const result = await profileClient.get(); - expect(result).toEqual(personalProfile); + expect(result).toEqual(individualProfile); })); }); @@ -86,10 +87,10 @@ describe('Storage Profile Client', function() { test('getPublic returns only the public projection', function() { return(withProfile(randomSeed(), async function({ profileClient }) { - await profileClient.set(personalProfile); + await profileClient.set(individualProfile); const result = await profileClient.getPublic(); - expect(result).toEqual({ accountType: 'personal', displayName: 'Alice' }); + expect(result).toEqual({ accountType: 'individual', displayName: 'Alice' }); })); }); @@ -104,7 +105,7 @@ describe('Storage Profile Client', function() { test('stores private and public objects', function() { return(withProfile(randomSeed(), async function({ profileClient, provider, account }) { - await profileClient.set(personalProfile); + await profileClient.set(individualProfile); const [ privateMeta, publicMeta ] = await Promise.all([ provider.getMetadata({ path: privatePath(account), account }), @@ -118,7 +119,7 @@ describe('Storage Profile Client', function() { test('the private object is not readable via a public URL', function() { return(withProfile(randomSeed(), async function({ profileClient, provider, account }) { - await profileClient.set(personalProfile); + await profileClient.set(individualProfile); const url = await provider.getPublicUrl({ path: privatePath(account), account }); const response = await fetch(url); @@ -136,7 +137,7 @@ describe('Storage Profile Client', function() { test('set overwrites an existing profile and can switch account type', function() { return(withProfile(randomSeed(), async function({ profileClient }) { - await profileClient.set(personalProfile); + await profileClient.set(individualProfile); await profileClient.set(businessProfile); expect(await profileClient.get()).toEqual(businessProfile); @@ -149,8 +150,8 @@ describe('Storage Profile Client', function() { const pubkey = account.publicKeyString.get(); const profileClient = provider.getProfileClient({ account, basePath: `/user/${pubkey}/custom-profile/` }); - await profileClient.set(personalProfile); - expect(await profileClient.get()).toEqual(personalProfile); + await profileClient.set(individualProfile); + expect(await profileClient.get()).toEqual(individualProfile); const customMeta = await provider.getMetadata({ path: `/user/${pubkey}/custom-profile/private`, account }); expect(customMeta?.visibility).toBe('private'); @@ -162,7 +163,7 @@ describe('Storage Profile Client', function() { test('delete removes both objects', function() { return(withProfile(randomSeed(), async function({ profileClient }) { - await profileClient.set(personalProfile); + await profileClient.set(individualProfile); expect(await profileClient.delete()).toBe(true); expect(await profileClient.get()).toBeNull(); @@ -217,11 +218,11 @@ describe('Storage Profile Client', function() { test('get returns null when either object is missing', function() { return(withProfile(randomSeed(), async function({ profileClient, provider, account }) { - await profileClient.set(personalProfile); + await profileClient.set(individualProfile); await provider.delete({ path: publicPath(account), account }); expect(await profileClient.get()).toBeNull(); - await profileClient.set(personalProfile); + await profileClient.set(individualProfile); await provider.delete({ path: privatePath(account), account }); expect(await profileClient.get()).toBeNull(); })); diff --git a/src/services/storage/clients/profile.ts b/src/services/storage/clients/profile.ts index c78cb56b..db942b93 100644 --- a/src/services/storage/clients/profile.ts +++ b/src/services/storage/clients/profile.ts @@ -19,7 +19,7 @@ interface BaseProfile { /** * The private fields of an individual account, stored on their own in the private object. */ -export interface PersonalPrivateProfile { +export interface IndividualPrivateProfile { firstName: string; lastName: string; } @@ -37,13 +37,13 @@ export interface BusinessPrivateProfile { * A non-discriminated union, but its members have disjoint required keys, so typia * validates it structurally. */ -export type PrivateProfile = PersonalPrivateProfile | BusinessPrivateProfile; +export type PrivateProfile = IndividualPrivateProfile | BusinessPrivateProfile; /** * A profile for an individual account. * `firstName` and `lastName` are private; `displayName` and `accountType` are public. */ -export type PersonalProfile = BaseProfile<'personal'> & PersonalPrivateProfile; +export type IndividualProfile = BaseProfile<'individual'> & IndividualPrivateProfile; /** * A profile for a business account. @@ -54,7 +54,7 @@ export type BusinessProfile = BaseProfile<'business'> & BusinessPrivateProfile; /** * A full account profile, discriminated on `accountType`. */ -export type Profile = PersonalProfile | BusinessProfile; +export type Profile = IndividualProfile | BusinessProfile; /** * The set of account types a profile can have. @@ -125,7 +125,7 @@ export class StorageProfileClient implements ProfileClient { } #toPrivate(profile: Profile): PrivateProfile { - if (profile.accountType === 'personal') { + if (profile.accountType === 'individual') { return({ firstName: profile.firstName, lastName: profile.lastName From 1dba29c1d40d04f635f77a4c6cc442647d389b2f Mon Sep 17 00:00:00 2001 From: Lucas Rosa Date: Wed, 16 Sep 2026 16:43:54 -0300 Subject: [PATCH 9/9] Update src/lib/resolver.ts Co-authored-by: ezraripps <19670988+ezraripps@users.noreply.github.com> --- src/lib/resolver.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/lib/resolver.ts b/src/lib/resolver.ts index 37d05979..4cd5a526 100644 --- a/src/lib/resolver.ts +++ b/src/lib/resolver.ts @@ -2683,8 +2683,11 @@ class Resolver { continue; } - if (entityType !== undefined && !await kycServiceSupportsEntityType(kycService, entityType)) { - continue; + if (entityType !== undefined) { + const isSupportedType = await kycServiceSupportsEntityType(kycService, entityType) + if (!isSupportedType) { + continue; + } } /*