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.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/lib/resolver.ts b/src/lib/resolver.ts index 7d048ac8..4cd5a526 100644 --- a/src/lib/resolver.ts +++ b/src/lib/resolver.ts @@ -49,6 +49,18 @@ type CountrySearchInput = CurrencyInfo.ISOCountryCode | CurrencyInfo.ISOCountryN type CountrySearchCanonical = CurrencyInfo.ISOCountryCode; /* XXX:TODO */ // #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. + */ +const kycEntityTypes = ['individual', 'business'] as const; +type KYCEntityType = typeof kycEntityTypes[number]; + /** * Service Metadata General Structure */ @@ -108,6 +120,23 @@ type ServiceMetadata = { * validate accounts in any country. */ countryCodes?: string[]; + /** + * The entity types which this KYC provider can + * 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 type is supported only when + * its key is explicitly `true`; `false` or a missing + * key both mean unsupported. + */ + entityTypes?: { [entityType in KYCEntityType]?: boolean }; /** * The Certificate Authority (CA) Certificate * that this KYC provider uses to sign KYC @@ -361,6 +390,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?: KYCEntityType; }; 'assetMovement': { asset?: MovableAssetSearchInput | { from: MovableAssetSearchInput; to: MovableAssetSearchInput; }; @@ -1609,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']; @@ -1818,6 +1872,16 @@ class Resolver { } } + if (criteria.entityType !== undefined) { + const supported = await kycServiceSupportsEntityType(checkKYCService, criteria.entityType); + + this.#logger?.debug(`Resolver:${this.id}`, 'Checking entity type:', criteria.entityType, 'supported:', supported, 'for', checkKYCServiceID); + + if (!supported) { + continue; + } + } + retval[checkKYCServiceID] = assertResolverLookupKYCResult(checkKYCService); } catch (checkKYCServiceError) { this.#logger?.debug(`Resolver:${this.id}`, 'Error checking KYC service', checkKYCServiceID, ':', checkKYCServiceError, ' -- ignoring'); @@ -2586,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(); /* @@ -2615,6 +2683,13 @@ class Resolver { continue; } + if (entityType !== undefined) { + const isSupportedType = await kycServiceSupportsEntityType(kycService, entityType) + if (!isSupportedType) { + continue; + } + } + /* * If the KYC service does not have a countryCodes * property, then it can validate accounts in any @@ -2900,7 +2975,9 @@ class Resolver { } export default Resolver; +export { kycEntityTypes }; export type { + KYCEntityType, ServiceMetadata, ServiceMetadataExternalizable, ServiceSearchCriteria, diff --git a/src/services/kyc/client.test.ts b/src/services/kyc/client.test.ts index ef2e4709..f2142d83 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,31 @@ 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(); + 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); @@ -306,3 +332,103 @@ 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, + id: crypto.randomUUID(), + 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); + + /* + * 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); + }); + 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 3c1b170c..1469a505 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'; @@ -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) { @@ -296,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 } : {}) })); } } @@ -382,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')); } @@ -518,12 +524,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 106987cb..d2fc5061 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'; @@ -29,6 +30,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 +60,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..686b740a 100644 --- a/src/services/kyc/server.test.ts +++ b/src/services/kyc/server.test.ts @@ -122,3 +122,262 @@ 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() { + 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?.('object'); + expect(declaredEntityTypes).toBeDefined(); + expect(await declaredEntityTypes?.business?.('boolean')).toBe(true); + expect(await declaredEntityTypes?.individual?.('boolean')).toBe(true); + + const individualMatch = await resolver.lookup('kyc', { + countryCodes: ['US'], + entityType: 'individual' + }); + expect(individualMatch).toBeDefined(); + 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, 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); + 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` 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. + */ + 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); + + const server = new KeetaNetKYCAnchorHTTPServer({ + signer: providerSigner, + ca: kycCA, + client: providerClient, + kycProviderURL: 'https://example.com/journey/{id}', + kyc: { + countryCodes: countryCodes, + ...(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(); + startedServers.push(server); + + 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: [] + }); + + async function matches(requested: 'individual' | 'business', requestedCountryCodes: ('US' | 'CA')[]) { + const match = await resolver.lookup('kyc', { + countryCodes: requestedCountryCodes, + entityType: requested + }); + + return(match !== undefined && 'Test' in match); + } + + return({ matches, resolver }); + } + + 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; + 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: '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) { + 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 startedServer of startedServers) { + await startedServer[Symbol.asyncDispose](); + } + } +}); + diff --git a/src/services/kyc/server.ts b/src/services/kyc/server.ts index ceff0643..3ce58e93 100644 --- a/src/services/kyc/server.ts +++ b/src/services/kyc/server.ts @@ -21,7 +21,8 @@ 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'; @@ -130,6 +131,19 @@ 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 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[]; } /** @@ -168,6 +182,7 @@ export class KeetaNetKYCAnchorHTTPServer extends KeetaAnchorMetadataServer; readonly routes: NonNullable; readonly #countryCodes?: CurrencyInfo.Country[] | undefined; + readonly #entityTypes: KYCEntityType[]; constructor(config: KeetaAnchorKYCServerConfig) { super(config); @@ -179,6 +194,11 @@ export class KeetaNetKYCAnchorHTTPServer extends KeetaAnchorMetadataServer { + return([entityType, this.#entityTypes.includes(entityType)]); + })), operations }); } 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