From 6c57350449a1c4fd0f360b2ee6c8e8fb7413c564 Mon Sep 17 00:00:00 2001 From: Henry Tsai Date: Mon, 6 May 2024 17:06:50 -0700 Subject: [PATCH 1/2] #497 - Added support for publishing NS records + official test vector 2 compliance (#514) - Added support for publishing NS records and fixed bugs to achieve official test vector 2 compliance. - Removed kid in DNS records according to DID DHT spec update. - Minor renaming. - QoL - Updated CODEOWNERS to further increase review efficiency. - QoL - Added HTML code coverage output for `dids` repo for immediate coverage feedback. --- CODEOWNERS | 29 +++-- packages/dids/.c8rc.json | 1 + packages/dids/src/methods/did-dht.ts | 94 +++++++++++----- .../did-dht/vector-1-did-document.json | 29 ----- .../did-dht/vector-1-dns-records.json | 14 --- .../test-vectors/did-dht/vector-1.json | 45 ++++++++ .../test-vectors/did-dht/vector-2.json | 105 ++++++++++++++++++ packages/dids/tests/methods/did-dht.spec.ts | 54 ++++++--- 8 files changed, 275 insertions(+), 96 deletions(-) delete mode 100644 packages/dids/tests/fixtures/test-vectors/did-dht/vector-1-did-document.json delete mode 100644 packages/dids/tests/fixtures/test-vectors/did-dht/vector-1-dns-records.json create mode 100644 packages/dids/tests/fixtures/test-vectors/did-dht/vector-1.json create mode 100644 packages/dids/tests/fixtures/test-vectors/did-dht/vector-2.json diff --git a/CODEOWNERS b/CODEOWNERS index b0f6d2cd7..eb01b33a9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -8,19 +8,28 @@ # These owners will be the default owners for everything in the repo. * @frankhinek @csuwildcat @mistermoe @thehenrytsai @lirancohen +# These are owners who can approve folders under the root directory and other CICD and QoL directories. +# Should be the union list of all owners of sub-directories, optionally minus the default owners. +/* @diehuxx @shamilovtim @nitro-neal +/.changeset @diehuxx @shamilovtim @nitro-neal +/.codesandbox @diehuxx @shamilovtim @nitro-neal +/.github @diehuxx @shamilovtim @nitro-neal +/.vscode @diehuxx @shamilovtim @nitro-neal +/scripts @diehuxx @shamilovtim @nitro-neal + # These are owners of any file in the `common`, `crypto`, `crypto-aws-kms`, `dids`, and # `credentials` packages and their sub-directories. -/packages/common @diehuxx @mistermoe @frankhinek @thehenrytsai @nitro-neal -/packages/crypto @diehuxx @mistermoe @frankhinek @thehenrytsai -/packages/crypto-aws-kms @diehuxx @mistermoe @frankhinek @thehenrytsai -/packages/dids @diehuxx @mistermoe @frankhinek @thehenrytsai @nitro-neal -/packages/credentials @diehuxx @mistermoe @frankhinek @thehenrytsai @nitro-neal +/packages/common @diehuxx @thehenrytsai @nitro-neal +/packages/crypto @diehuxx @thehenrytsai +/packages/crypto-aws-kms @diehuxx @thehenrytsai +/packages/dids @diehuxx @thehenrytsai @nitro-neal +/packages/credentials @diehuxx @thehenrytsai @nitro-neal # These are owners of any file in the `agent`, `user-agent`, `proxy-agent`, `identity-agent`, and # `api` packages and their sub-directories. -/packages/agent @lirancohen @frankhinek @csuwildcat @mistermoe @shamilovtim -/packages/proxy-agent @lirancohen @frankhinek @csuwildcat @mistermoe @shamilovtim -/packages/user-agent @lirancohen @frankhinek @csuwildcat @mistermoe @shamilovtim -/packages/identity-agent @lirancohen @frankhinek @csuwildcat @mistermoe @shamilovtim -/packages/api @lirancohen @frankhinek @csuwildcat @mistermoe @shamilovtim @nitro-neal +/packages/agent @lirancohen @csuwildcat @shamilovtim +/packages/proxy-agent @lirancohen @csuwildcat @shamilovtim +/packages/user-agent @lirancohen @csuwildcat @shamilovtim +/packages/identity-agent @lirancohen @csuwildcat @shamilovtim +/packages/api @lirancohen @csuwildcat @shamilovtim @nitro-neal diff --git a/packages/dids/.c8rc.json b/packages/dids/.c8rc.json index c44b67aba..e96f8a5a3 100644 --- a/packages/dids/.c8rc.json +++ b/packages/dids/.c8rc.json @@ -14,6 +14,7 @@ ], "reporter": [ "cobertura", + "html", "text" ] } \ No newline at end of file diff --git a/packages/dids/src/methods/did-dht.ts b/packages/dids/src/methods/did-dht.ts index b5acb8b25..8add749c0 100644 --- a/packages/dids/src/methods/did-dht.ts +++ b/packages/dids/src/methods/did-dht.ts @@ -1,4 +1,4 @@ -import type { Packet, TxtAnswer, TxtData } from '@dnsquery/dns-packet'; +import type { Packet, StringAnswer, TxtAnswer, TxtData } from '@dnsquery/dns-packet'; import type { Jwk, Signer, @@ -533,17 +533,17 @@ export class DidDht extends DidMethod { // Generate random key material for the Identity Key and any additional verification methods. // Add verification methods to the DID document. - for (const vm of verificationMethodsToAdd) { + for (const verificationMethod of verificationMethodsToAdd) { // Generate a random key for the verification method, or if its the Identity Key's // verification method (`id` is 0) use the key previously generated. - const keyUri = (vm.id && vm.id.split('#').pop() === '0') + const keyUri = (verificationMethod.id && verificationMethod.id.split('#').pop() === '0') ? identityKeyUri - : await keyManager.generateKey({ algorithm: vm.algorithm }); + : await keyManager.generateKey({ algorithm: verificationMethod.algorithm }); const publicKey = await keyManager.getPublicKey({ keyUri }); // Use the given ID, the key's ID, or the key's thumbprint as the verification method ID. - let methodId = vm.id ?? publicKey.kid ?? await computeJwkThumbprint({ jwk: publicKey }); + let methodId = verificationMethod.id ?? publicKey.kid ?? await computeJwkThumbprint({ jwk: publicKey }); methodId = `${didUri}#${extractDidFragment(methodId)}`; // Remove fragment prefix, if any. // Initialize the `verificationMethod` array if it does not already exist. @@ -553,12 +553,12 @@ export class DidDht extends DidMethod { document.verificationMethod.push({ id : methodId, type : 'JsonWebKey', - controller : vm.controller ?? didUri, + controller : verificationMethod.controller ?? didUri, publicKeyJwk : publicKey, }); // Add the verification method to the specified purpose properties of the DID document. - for (const purpose of vm.purposes ?? []) { + for (const purpose of verificationMethod.purposes ?? []) { // Initialize the purpose property if it does not already exist. if (!document[purpose]) document[purpose] = []; // Add the verification method to the purpose property. @@ -825,8 +825,9 @@ export class DidDhtDocument { }): Promise { // Convert the DID document and DID metadata (such as DID types) to a DNS packet. const dnsPacket = await DidDhtDocument.toDnsPacket({ - didDocument : did.document, - didMetadata : did.metadata + didDocument : did.document, + didMetadata : did.metadata, + authoritativeGatewayUris : [gatewayUri] }); // Create a signed BEP44 put message from the DNS packet. @@ -1118,22 +1119,25 @@ export class DidDhtDocument { * @param params - The parameters to use when converting a DID document to a DNS packet. * @param params.didDocument - The DID document to convert to a DNS packet. * @param params.didMetadata - The DID metadata to include in the DNS packet. + * @param params.authoritativeGatewayUris - The URIs of the Authoritative Gateways to generate NS records from. * @returns A promise that resolves to a DNS packet. */ - public static async toDnsPacket({ didDocument, didMetadata }: { + public static async toDnsPacket({ didDocument, didMetadata, authoritativeGatewayUris }: { didDocument: DidDocument; didMetadata: DidMetadata; + authoritativeGatewayUris?: string[]; }): Promise { - const dnsAnswerRecords: TxtAnswer[] = []; + const txtRecords: TxtAnswer[] = []; + const nsRecords: StringAnswer[] = []; const idLookup = new Map(); const serviceIds: string[] = []; const verificationMethodIds: string[] = []; // Add DNS TXT records if the DID document contains an `alsoKnownAs` property. if (didDocument.alsoKnownAs) { - dnsAnswerRecords.push({ + txtRecords.push({ type : 'TXT', - name : '_aka.did.', + name : '_aka._did.', ttl : DNS_RECORD_TTL, data : didDocument.alsoKnownAs.join(VALUE_SEPARATOR) }); @@ -1144,25 +1148,25 @@ export class DidDhtDocument { const controller = Array.isArray(didDocument.controller) ? didDocument.controller.join(VALUE_SEPARATOR) : didDocument.controller; - dnsAnswerRecords.push({ + txtRecords.push({ type : 'TXT', - name : '_cnt.did.', + name : '_cnt._did.', ttl : DNS_RECORD_TTL, data : controller }); } // Add DNS TXT records for each verification method. - for (const [index, vm] of didDocument.verificationMethod?.entries() ?? []) { + for (const [index, verificationMethod] of didDocument.verificationMethod?.entries() ?? []) { const dnsRecordId = `k${index}`; verificationMethodIds.push(dnsRecordId); - let methodId = vm.id.split('#').pop()!; // Remove fragment prefix, if any. + let methodId = verificationMethod.id.split('#').pop()!; // Remove fragment prefix, if any. idLookup.set(methodId, dnsRecordId); - const publicKey = vm.publicKeyJwk; + const publicKey = verificationMethod.publicKeyJwk; if (!(publicKey?.crv && publicKey.crv in AlgorithmToKeyTypeMap)) { - throw new DidError(DidErrorCode.InvalidPublicKeyType, `Verification method '${vm.id}' contains an unsupported key type: ${publicKey?.crv ?? 'undefined'}`); + throw new DidError(DidErrorCode.InvalidPublicKeyType, `Verification method '${verificationMethod.id}' contains an unsupported key type: ${publicKey?.crv ?? 'undefined'}`); } // Use the public key's `crv` property to get the DID DHT key type. @@ -1175,13 +1179,13 @@ export class DidDhtDocument { const publicKeyBase64Url = Convert.uint8Array(publicKeyBytes).toBase64Url(); // Define the data for the DNS TXT record. - const txtData = [`id=${methodId}`, `t=${keyType}`, `k=${publicKeyBase64Url}`]; + const txtData = [`t=${keyType}`, `k=${publicKeyBase64Url}`]; // Add the controller property, if set to a value other than the Identity Key (DID Subject). - if (vm.controller !== didDocument.id) txtData.push(`c=${vm.controller}`); + if (verificationMethod.controller !== didDocument.id) txtData.push(`c=${verificationMethod.controller}`); // Add a TXT record for the verification method. - dnsAnswerRecords.push({ + txtRecords.push({ type : 'TXT', name : `_${dnsRecordId}._did.`, ttl : DNS_RECORD_TTL, @@ -1203,7 +1207,7 @@ export class DidDhtDocument { ); // Add a TXT record for the verification method. - dnsAnswerRecords.push({ + txtRecords.push({ type : 'TXT', name : `_${dnsRecordId}._did.`, ttl : DNS_RECORD_TTL, @@ -1244,7 +1248,7 @@ export class DidDhtDocument { const types = didMetadata.types as (DidDhtRegisteredDidType | keyof typeof DidDhtRegisteredDidType)[]; const typeIntegers = types.map(type => typeof type === 'string' ? DidDhtRegisteredDidType[type] : type); - dnsAnswerRecords.push({ + txtRecords.push({ type : 'TXT', name : '_typ._did.', ttl : DNS_RECORD_TTL, @@ -1253,19 +1257,29 @@ export class DidDhtDocument { } // Add a DNS TXT record for the root record. - dnsAnswerRecords.push({ + txtRecords.push({ type : 'TXT', name : '_did.' + DidDhtDocument.getUniqueDidSuffix(didDocument.id) + '.', // name of a Root Record MUST end in `.` ttl : DNS_RECORD_TTL, data : rootRecord.join(PROPERTY_SEPARATOR) }); + // Add an NS record for each authoritative gateway URI. + for (const gatewayUri of authoritativeGatewayUris || []) { + nsRecords.push({ + type : 'NS', + name : '_did.' + DidDhtDocument.getUniqueDidSuffix(didDocument.id) + '.', // name of an NS record a authoritative gateway MUST end in `.` + ttl : DNS_RECORD_TTL, + data : gatewayUri + '.' + }); + } + // Create a DNS response packet with the authoritative answer flag set. const dnsPacket: Packet = { id : 0, type : 'response', flags : AUTHORITATIVE_ANSWER, - answers : dnsAnswerRecords + answers : [...txtRecords, ...nsRecords] }; return dnsPacket; @@ -1412,9 +1426,31 @@ export class DidDhtUtils { */ public static keyConverter(curve: string): AsymmetricKeyConverter { const converters: Record = { - 'Ed25519' : Ed25519, - 'P-256' : Secp256r1, - 'secp256k1' : Secp256k1 + 'Ed25519' : Ed25519, + 'P-256' : { + // Wrap the key converter which produces uncompressed public key bytes to produce compressed key bytes as required by the DID DHT spec. + // See https://did-dht.com/#representing-keys for more info. + publicKeyToBytes: async ({ publicKey }: { publicKey: Jwk }): Promise => { + const publicKeyBytes = await Secp256r1.publicKeyToBytes({ publicKey }); + const compressedPublicKey = await Secp256r1.compressPublicKey({ publicKeyBytes }); + return compressedPublicKey; + }, + bytesToPublicKey : Secp256r1.bytesToPublicKey, + privateKeyToBytes : Secp256r1.privateKeyToBytes, + bytesToPrivateKey : Secp256r1.bytesToPrivateKey, + }, + 'secp256k1': { + // Wrap the key converter which produces uncompressed public key bytes to produce compressed key bytes as required by the DID DHT spec. + // See https://did-dht.com/#representing-keys for more info. + publicKeyToBytes: async ({ publicKey }: { publicKey: Jwk }): Promise => { + const publicKeyBytes = await Secp256k1.publicKeyToBytes({ publicKey }); + const compressedPublicKey = await Secp256k1.compressPublicKey({ publicKeyBytes }); + return compressedPublicKey; + }, + bytesToPublicKey : Secp256k1.bytesToPublicKey, + privateKeyToBytes : Secp256k1.privateKeyToBytes, + bytesToPrivateKey : Secp256k1.bytesToPrivateKey, + } }; const converter = converters[curve]; diff --git a/packages/dids/tests/fixtures/test-vectors/did-dht/vector-1-did-document.json b/packages/dids/tests/fixtures/test-vectors/did-dht/vector-1-did-document.json deleted file mode 100644 index 3b20263c6..000000000 --- a/packages/dids/tests/fixtures/test-vectors/did-dht/vector-1-did-document.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "id": "did:dht:cyuoqaf7itop8ohww4yn5ojg13qaq83r9zihgqntc5i9zwrfdfoo", - "verificationMethod": [ - { - "id": "did:dht:cyuoqaf7itop8ohww4yn5ojg13qaq83r9zihgqntc5i9zwrfdfoo#0", - "type": "JsonWebKey", - "controller": "did:dht:cyuoqaf7itop8ohww4yn5ojg13qaq83r9zihgqntc5i9zwrfdfoo", - "publicKeyJwk": { - "kid": "0", - "alg": "Ed25519", - "crv": "Ed25519", - "kty": "OKP", - "x": "YCcHYL2sYNPDlKaALcEmll2HHyT968M4UWbr-9CFGWE" - } - } - ], - "authentication": [ - "did:dht:cyuoqaf7itop8ohww4yn5ojg13qaq83r9zihgqntc5i9zwrfdfoo#0" - ], - "assertionMethod": [ - "did:dht:cyuoqaf7itop8ohww4yn5ojg13qaq83r9zihgqntc5i9zwrfdfoo#0" - ], - "capabilityInvocation": [ - "did:dht:cyuoqaf7itop8ohww4yn5ojg13qaq83r9zihgqntc5i9zwrfdfoo#0" - ], - "capabilityDelegation": [ - "did:dht:cyuoqaf7itop8ohww4yn5ojg13qaq83r9zihgqntc5i9zwrfdfoo#0" - ] -} \ No newline at end of file diff --git a/packages/dids/tests/fixtures/test-vectors/did-dht/vector-1-dns-records.json b/packages/dids/tests/fixtures/test-vectors/did-dht/vector-1-dns-records.json deleted file mode 100644 index a749bfd0d..000000000 --- a/packages/dids/tests/fixtures/test-vectors/did-dht/vector-1-dns-records.json +++ /dev/null @@ -1,14 +0,0 @@ -[ - { - "name": "_did.cyuoqaf7itop8ohww4yn5ojg13qaq83r9zihgqntc5i9zwrfdfoo.", - "type": "TXT", - "ttl": 7200, - "rdata": "v=0;vm=k0;auth=k0;asm=k0;inv=k0;del=k0" - }, - { - "name": "_k0._did.", - "type": "TXT", - "ttl": 7200, - "rdata": "id=0;t=0;k=YCcHYL2sYNPDlKaALcEmll2HHyT968M4UWbr-9CFGWE" - } -] \ No newline at end of file diff --git a/packages/dids/tests/fixtures/test-vectors/did-dht/vector-1.json b/packages/dids/tests/fixtures/test-vectors/did-dht/vector-1.json new file mode 100644 index 000000000..3627b06c6 --- /dev/null +++ b/packages/dids/tests/fixtures/test-vectors/did-dht/vector-1.json @@ -0,0 +1,45 @@ +{ + "didDocument": { + "id": "did:dht:cyuoqaf7itop8ohww4yn5ojg13qaq83r9zihgqntc5i9zwrfdfoo", + "verificationMethod": [ + { + "id": "did:dht:cyuoqaf7itop8ohww4yn5ojg13qaq83r9zihgqntc5i9zwrfdfoo#0", + "type": "JsonWebKey", + "controller": "did:dht:cyuoqaf7itop8ohww4yn5ojg13qaq83r9zihgqntc5i9zwrfdfoo", + "publicKeyJwk": { + "kid": "0", + "alg": "Ed25519", + "crv": "Ed25519", + "kty": "OKP", + "x": "YCcHYL2sYNPDlKaALcEmll2HHyT968M4UWbr-9CFGWE" + } + } + ], + "authentication": [ + "did:dht:cyuoqaf7itop8ohww4yn5ojg13qaq83r9zihgqntc5i9zwrfdfoo#0" + ], + "assertionMethod": [ + "did:dht:cyuoqaf7itop8ohww4yn5ojg13qaq83r9zihgqntc5i9zwrfdfoo#0" + ], + "capabilityInvocation": [ + "did:dht:cyuoqaf7itop8ohww4yn5ojg13qaq83r9zihgqntc5i9zwrfdfoo#0" + ], + "capabilityDelegation": [ + "did:dht:cyuoqaf7itop8ohww4yn5ojg13qaq83r9zihgqntc5i9zwrfdfoo#0" + ] + }, + "dnsRecords": [ + { + "name": "_did.cyuoqaf7itop8ohww4yn5ojg13qaq83r9zihgqntc5i9zwrfdfoo.", + "type": "TXT", + "ttl": 7200, + "rdata": "v=0;vm=k0;auth=k0;asm=k0;inv=k0;del=k0" + }, + { + "name": "_k0._did.", + "type": "TXT", + "ttl": 7200, + "rdata": "t=0;k=YCcHYL2sYNPDlKaALcEmll2HHyT968M4UWbr-9CFGWE" + } + ] +} \ No newline at end of file diff --git a/packages/dids/tests/fixtures/test-vectors/did-dht/vector-2.json b/packages/dids/tests/fixtures/test-vectors/did-dht/vector-2.json new file mode 100644 index 000000000..72a212672 --- /dev/null +++ b/packages/dids/tests/fixtures/test-vectors/did-dht/vector-2.json @@ -0,0 +1,105 @@ +{ + "didDocument": { + "id": "did:dht:cyuoqaf7itop8ohww4yn5ojg13qaq83r9zihgqntc5i9zwrfdfoo", + "controller": "did:example:abcd", + "alsoKnownAs": ["did:example:efgh", "did:example:ijkl"], + "verificationMethod": [ + { + "id": "did:dht:cyuoqaf7itop8ohww4yn5ojg13qaq83r9zihgqntc5i9zwrfdfoo#0", + "type": "JsonWebKey", + "controller": "did:dht:cyuoqaf7itop8ohww4yn5ojg13qaq83r9zihgqntc5i9zwrfdfoo", + "publicKeyJwk": { + "kid": "0", + "alg": "Ed25519", + "crv": "Ed25519", + "kty": "OKP", + "x": "YCcHYL2sYNPDlKaALcEmll2HHyT968M4UWbr-9CFGWE" + } + }, + { + "id": "did:dht:cyuoqaf7itop8ohww4yn5ojg13qaq83r9zihgqntc5i9zwrfdfoo#0GkvkdCGu3DL7Mkv0W1DhTMCBT9-z0CkFqZoJQtw7vw", + "type": "JsonWebKey", + "controller": "did:dht:i9xkp8ddcbcg8jwq54ox699wuzxyifsqx4jru45zodqu453ksz6y", + "publicKeyJwk": { + "kid": "0GkvkdCGu3DL7Mkv0W1DhTMCBT9-z0CkFqZoJQtw7vw", + "alg": "ES256K", + "crv": "secp256k1", + "kty": "EC", + "x": "1_o0IKHGNamet8-3VYNUTiKlhVK-LilcKrhJSPHSNP0", + "y": "qzU8qqh0wKB6JC_9HCu8pHE-ZPkDpw4AdJ-MsV2InVY" + } + } + ], + "authentication": [ + "did:dht:cyuoqaf7itop8ohww4yn5ojg13qaq83r9zihgqntc5i9zwrfdfoo#0" + ], + "assertionMethod": [ + "did:dht:cyuoqaf7itop8ohww4yn5ojg13qaq83r9zihgqntc5i9zwrfdfoo#0", + "did:dht:cyuoqaf7itop8ohww4yn5ojg13qaq83r9zihgqntc5i9zwrfdfoo#0GkvkdCGu3DL7Mkv0W1DhTMCBT9-z0CkFqZoJQtw7vw" + ], + "capabilityInvocation": [ + "did:dht:cyuoqaf7itop8ohww4yn5ojg13qaq83r9zihgqntc5i9zwrfdfoo#0", + "did:dht:cyuoqaf7itop8ohww4yn5ojg13qaq83r9zihgqntc5i9zwrfdfoo#0GkvkdCGu3DL7Mkv0W1DhTMCBT9-z0CkFqZoJQtw7vw" + ], + "capabilityDelegation": [ + "did:dht:cyuoqaf7itop8ohww4yn5ojg13qaq83r9zihgqntc5i9zwrfdfoo#0" + ], + "service": [ + { + "id": "did:dht:cyuoqaf7itop8ohww4yn5ojg13qaq83r9zihgqntc5i9zwrfdfoo#service-1", + "type": "TestService", + "serviceEndpoint": ["https://test-service.com/1", "https://test-service.com/2"] + } + ] + }, + "dnsRecords": [ + { + "name": "_did.cyuoqaf7itop8ohww4yn5ojg13qaq83r9zihgqntc5i9zwrfdfoo.", + "type": "NS", + "ttl": 7200, + "rdata": "gateway1.example-did-dht-gateway.com." + }, + { + "name": "_did.cyuoqaf7itop8ohww4yn5ojg13qaq83r9zihgqntc5i9zwrfdfoo.", + "type": "TXT", + "ttl": 7200, + "rdata": "v=0;vm=k0,k1;auth=k0;asm=k0,k1;inv=k0,k1;del=k0;svc=s0" + }, + { + "name": "_cnt._did.", + "type": "TXT", + "ttl": 7200, + "rdata": "did:example:abcd" + }, + { + "name": "_aka._did.", + "type": "TXT", + "ttl": 7200, + "rdata": "did:example:efgh,did:example:ijkl" + }, + { + "name": "_k0._did.", + "type": "TXT", + "ttl": 7200, + "rdata": "t=0;k=YCcHYL2sYNPDlKaALcEmll2HHyT968M4UWbr-9CFGWE" + }, + { + "name": "_k1._did.", + "type": "TXT", + "ttl": 7200, + "rdata": "t=1;k=Atf6NCChxjWpnrfPt1WDVE4ipYVSvi4pXCq4SUjx0jT9;c=did:dht:i9xkp8ddcbcg8jwq54ox699wuzxyifsqx4jru45zodqu453ksz6y" + }, + { + "name": "_s0._did.", + "type": "TXT", + "ttl": 7200, + "rdata": "id=service-1;t=TestService;se=https://test-service.com/1,https://test-service.com/2" + }, + { + "name": "_typ._did.", + "type": "TXT", + "ttl": 7200, + "rdata": "id=1,2,3" + } + ] +} \ No newline at end of file diff --git a/packages/dids/tests/methods/did-dht.spec.ts b/packages/dids/tests/methods/did-dht.spec.ts index 929362888..5bc39dcd5 100644 --- a/packages/dids/tests/methods/did-dht.spec.ts +++ b/packages/dids/tests/methods/did-dht.spec.ts @@ -2,10 +2,11 @@ import type { PortableDid } from '../../src/types/portable-did.js'; import sinon from 'sinon'; import resolveTestVectors from '../../../../web5-spec/test-vectors/did_dht/resolve.json' assert { type: 'json' }; -import officialTestVector1DidDocument from '../fixtures/test-vectors/did-dht/vector-1-did-document.json' assert { type: 'json' }; -import officialTestVector1DnsRecords from '../fixtures/test-vectors/did-dht/vector-1-dns-records.json' assert { type: 'json' }; +import officialTestVector1 from '../fixtures/test-vectors/did-dht/vector-1.json' assert { type: 'json' }; +import officialTestVector2 from '../fixtures/test-vectors/did-dht/vector-2.json' assert { type: 'json' }; import { expect } from 'chai'; +import { Answer } from '@dnsquery/dns-packet'; import { Convert } from '@web5/common'; import { DidDocument } from '../../src/index.js'; import { DidErrorCode } from '../../src/did-error.js'; @@ -1172,23 +1173,48 @@ describe('DidDhtDocument', () => { describe('Official DID:DHT Vector tests', () => { it('vector 1', async () => { const dnsPacket = await DidDhtDocument.toDnsPacket({ - didDocument : officialTestVector1DidDocument as DidDocument, + didDocument : officialTestVector1.didDocument as DidDocument, didMetadata : { published: false } }); - expect(dnsPacket.answers).to.have.length(officialTestVector1DnsRecords.length); + expect(dnsPacket.answers).to.have.length(officialTestVector1.dnsRecords.length); - // NOTE: the DNS library we use uses name `data` instead of `rdata` used in DID:DHT spec, - // but prefer to keep the naming in test vector files identical to that of the DID:DHT spec, - // hence this additional normalization step - const normalizedConstructedRecords = dnsPacket.answers!.map(record => { - const { data: rdata, ...otherProperties } = record; - return { - ...otherProperties, - rdata - }; + const normalizedConstructedRecords = normalizeDnsRecords(dnsPacket.answers!); + expect(normalizedConstructedRecords).to.deep.include.members(officialTestVector1.dnsRecords); + }); + + it('vector 2', async () => { + const dnsPacket = await DidDhtDocument.toDnsPacket({ + didDocument : officialTestVector2.didDocument as DidDocument, + didMetadata : { + published : false, + types : [DidDhtRegisteredDidType.Organization, DidDhtRegisteredDidType.Government, DidDhtRegisteredDidType.Corporation] + }, + authoritativeGatewayUris: ['gateway1.example-did-dht-gateway.com'] }); - expect(normalizedConstructedRecords).to.deep.include.members(officialTestVector1DnsRecords); + expect(dnsPacket.answers).to.have.length(officialTestVector2.dnsRecords.length); + + const normalizedConstructedRecords = normalizeDnsRecords(dnsPacket.answers!); + expect(normalizedConstructedRecords).to.deep.include.members(officialTestVector2.dnsRecords); }); }); + +/** + * Normalizes the DNS records in the format of the DNS library we use to the format used in the test vectors. + * + * NOTE: the DNS library we use uses name `data` instead of `rdata` used in DID:DHT spec, + * but prefer to keep the naming in test vector files identical to that of the DID:DHT spec hence this additional normalization step. + */ +function normalizeDnsRecords(dnsRecords: Answer[]): Record { + + const normalizedRecords = dnsRecords.map(record => { + const { data: rdata, ...otherProperties } = record; + return { + ...otherProperties, + rdata + }; + }); + + return normalizedRecords; +} \ No newline at end of file From eabe5ca780745d229d5df7a0e64f43a5283a10d7 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Tue, 7 May 2024 13:57:45 -0400 Subject: [PATCH 2/2] `@web5/agent` Adding `DwnServerInfo` to RPC Clients (#489) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added a `DwnServerInfo` HTTP client to get info from the `dwn-server`'s `/info` endpoint ``` export type ServerInfo = { /** the maximum file size the user can request to store */ maxFileSize: number, /** * an array of strings representing the server's registration requirements. * * ie. ['proof-of-work-sha256-v0', 'terms-of-service'] * */ registrationRequirements: string[], /** whether web socket support is enabled on this server */ webSocketSupport: boolean, } ``` This is helpful for retrieving registration requirements and whether the server supports sockets. It uses a `TTLCache` memory cache as the currently implemented and default, however additional caches can be easily added if necessary.  --- .changeset/old-hotels-yawn.md | 14 ++ packages/agent/.c8rc.json | 3 +- .../clients/dwn-server-info-cache-memory.ts | 79 ++++++++++ .../clients/http-dwn-rpc-client.ts | 40 +++++ .../prototyping/clients/server-info-types.ts | 21 +++ packages/agent/src/rpc-client.ts | 22 ++- .../clients/dwn-server-info-cache.spec.ts | 119 +++++++++++++++ packages/agent/tests/rpc-client.spec.ts | 144 +++++++++++++++++- 8 files changed, 434 insertions(+), 8 deletions(-) create mode 100644 .changeset/old-hotels-yawn.md create mode 100644 packages/agent/src/prototyping/clients/dwn-server-info-cache-memory.ts create mode 100644 packages/agent/src/prototyping/clients/server-info-types.ts create mode 100644 packages/agent/tests/prototyping/clients/dwn-server-info-cache.spec.ts diff --git a/.changeset/old-hotels-yawn.md b/.changeset/old-hotels-yawn.md new file mode 100644 index 000000000..4c00a3c49 --- /dev/null +++ b/.changeset/old-hotels-yawn.md @@ -0,0 +1,14 @@ +--- +"@web5/agent": patch +"@web5/identity-agent": patch +"@web5/proxy-agent": patch +"@web5/user-agent": patch +--- + +Add `DwnServerInfoRpc` to `Web5Rpc` for retrieving server specific info. + +Server Info includes: + - maxFileSize + - registrationRequirements + - webSocketSupport + diff --git a/packages/agent/.c8rc.json b/packages/agent/.c8rc.json index c44b67aba..e0b57f53e 100644 --- a/packages/agent/.c8rc.json +++ b/packages/agent/.c8rc.json @@ -10,7 +10,8 @@ "exclude": [ "tests/compiled/**/src/index.js", "tests/compiled/**/src/types.js", - "tests/compiled/**/src/types/**" + "tests/compiled/**/src/types/**", + "tests/compiled/**/src/prototyping/clients/*-types.js" ], "reporter": [ "cobertura", diff --git a/packages/agent/src/prototyping/clients/dwn-server-info-cache-memory.ts b/packages/agent/src/prototyping/clients/dwn-server-info-cache-memory.ts new file mode 100644 index 000000000..65179b945 --- /dev/null +++ b/packages/agent/src/prototyping/clients/dwn-server-info-cache-memory.ts @@ -0,0 +1,79 @@ + +import ms from 'ms'; +import { TtlCache } from '@web5/common'; +import { DwnServerInfoCache, ServerInfo } from './server-info-types.js'; + +/** + * Configuration parameters for creating an in-memory cache for DWN ServerInfo entries. + * + * Allows customization of the cache time-to-live (TTL) setting. + */ +export type DwnServerInfoCacheMemoryParams = { + /** + * Optional. The time-to-live for cache entries, expressed as a string (e.g., '1h', '15m'). + * Determines how long a cache entry should remain valid before being considered expired. + * + * Defaults to '15m' if not specified. + */ + ttl?: string; +} + +export class DwnServerInfoCacheMemory implements DwnServerInfoCache { + private cache: TtlCache; + + constructor({ ttl = '15m' }: DwnServerInfoCacheMemoryParams= {}) { + this.cache = new TtlCache({ ttl: ms(ttl) }); + } + + /** + * Retrieves a DWN ServerInfo entry from the cache. + * + * If the cached item has exceeded its TTL, it's scheduled for deletion and undefined is returned. + * + * @param dwnUrl - The DWN URL endpoint string used as the key for getting the entry. + * @returns The cached DWN ServerInfo entry or undefined if not found or expired. + */ + public async get(dwnUrl: string): Promise { + return this.cache.get(dwnUrl); + } + + /** + * Stores a DWN ServerInfo entry in the cache with a TTL. + * + * @param dwnUrl - The DWN URL endpoint string used as the key for storing the entry. + * @param value - The DWN ServerInfo entry to be cached. + * @returns A promise that resolves when the operation is complete. + */ + public async set(dwnUrl: string, value: ServerInfo): Promise { + this.cache.set(dwnUrl, value); + } + + /** + * Deletes a DWN ServerInfo entry from the cache. + * + * @param dwnUrl - The DWN URL endpoint string used as the key for deletion. + * @returns A promise that resolves when the operation is complete. + */ + public async delete(dwnUrl: string): Promise { + this.cache.delete(dwnUrl); + } + + /** + * Clears all entries from the cache. + * + * @returns A promise that resolves when the operation is complete. + */ + public async clear(): Promise { + this.cache.clear(); + } + + /** + * This method is a no-op but exists to be consistent with other DWN ServerInfo Cache + * implementations. + * + * @returns A promise that resolves immediately. + */ + public async close(): Promise { + // No-op since there is no underlying store to close. + } +} \ No newline at end of file diff --git a/packages/agent/src/prototyping/clients/http-dwn-rpc-client.ts b/packages/agent/src/prototyping/clients/http-dwn-rpc-client.ts index b4b36a955..7e31df907 100644 --- a/packages/agent/src/prototyping/clients/http-dwn-rpc-client.ts +++ b/packages/agent/src/prototyping/clients/http-dwn-rpc-client.ts @@ -3,11 +3,18 @@ import type { DwnRpc, DwnRpcRequest, DwnRpcResponse } from './dwn-rpc-types.js'; import { createJsonRpcRequest, parseJson } from './json-rpc.js'; import { utils as cryptoUtils } from '@web5/crypto'; +import { DwnServerInfoCache, ServerInfo } from './server-info-types.js'; +import { DwnServerInfoCacheMemory } from './dwn-server-info-cache-memory.js'; /** * HTTP client that can be used to communicate with Dwn Servers */ export class HttpDwnRpcClient implements DwnRpc { + private serverInfoCache: DwnServerInfoCache; + constructor(serverInfoCache?: DwnServerInfoCache) { + this.serverInfoCache = serverInfoCache ?? new DwnServerInfoCacheMemory(); + } + get transportProtocols() { return ['http:', 'https:']; } async sendDwnRequest(request: DwnRpcRequest): Promise { @@ -65,4 +72,37 @@ export class HttpDwnRpcClient implements DwnRpc { return reply as DwnRpcResponse; } + + async getServerInfo(dwnUrl: string): Promise { + const serverInfo = await this.serverInfoCache.get(dwnUrl); + if (serverInfo) { + return serverInfo; + } + + const url = new URL(dwnUrl); + + // add `/info` to the dwn server url path + url.pathname.endsWith('/') ? url.pathname += 'info' : url.pathname += '/info'; + + try { + const response = await fetch(url.toString()); + if(response.ok) { + const results = await response.json() as ServerInfo; + + // explicitly return and cache only the desired properties. + const serverInfo = { + registrationRequirements : results.registrationRequirements, + maxFileSize : results.maxFileSize, + webSocketSupport : results.webSocketSupport, + }; + this.serverInfoCache.set(dwnUrl, serverInfo); + + return serverInfo; + } else { + throw new Error(`HTTP (${response.status}) - ${response.statusText}`); + } + } catch(error: any) { + throw new Error(`Error encountered while processing response from ${url.toString()}: ${error.message}`); + } + } } diff --git a/packages/agent/src/prototyping/clients/server-info-types.ts b/packages/agent/src/prototyping/clients/server-info-types.ts new file mode 100644 index 000000000..228832325 --- /dev/null +++ b/packages/agent/src/prototyping/clients/server-info-types.ts @@ -0,0 +1,21 @@ +import { KeyValueStore } from '@web5/common'; + +export type ServerInfo = { + /** the maximum file size the user can request to store */ + maxFileSize: number, + /** + * an array of strings representing the server's registration requirements. + * + * ie. ['proof-of-work-sha256-v0', 'terms-of-service'] + * */ + registrationRequirements: string[], + /** whether web socket support is enabled on this server */ + webSocketSupport: boolean, +} + +export interface DwnServerInfoCache extends KeyValueStore {} + +export interface DwnServerInfoRpc { + /** retrieves the DWN Sever info, used to detect features such as WebSocket Subscriptions */ + getServerInfo(url: string): Promise; +} \ No newline at end of file diff --git a/packages/agent/src/rpc-client.ts b/packages/agent/src/rpc-client.ts index 25c479c1c..9edf16c53 100644 --- a/packages/agent/src/rpc-client.ts +++ b/packages/agent/src/rpc-client.ts @@ -2,6 +2,7 @@ import { utils as cryptoUtils } from '@web5/crypto'; import type { DwnRpc, DwnRpcRequest, DwnRpcResponse } from './prototyping/clients/dwn-rpc-types.js'; +import type { DwnServerInfoRpc, ServerInfo } from './prototyping/clients/server-info-types.js'; import type { JsonRpcResponse } from './prototyping/clients/json-rpc.js'; import { createJsonRpcRequest } from './prototyping/clients/json-rpc.js'; @@ -39,7 +40,7 @@ export type RpcStatus = { message: string; }; -export interface Web5Rpc extends DwnRpc, DidRpc {} +export interface Web5Rpc extends DwnRpc, DidRpc, DwnServerInfoRpc {} /** * Client used to communicate with Dwn Servers @@ -94,6 +95,21 @@ export class Web5RpcClient implements Web5Rpc { return transportClient.sendDwnRequest(request); } + + async getServerInfo(dwnUrl: string): Promise { + // will throw if url is invalid + const url = new URL(dwnUrl); + + const transportClient = this.transportClients.get(url.protocol); + if(!transportClient) { + const error = new Error(`no ${url.protocol} transport client available`); + error.name = 'NO_TRANSPORT_CLIENT'; + + throw error; + } + + return transportClient.getServerInfo(dwnUrl); + } } export class HttpWeb5RpcClient extends HttpDwnRpcClient implements Web5Rpc { @@ -139,4 +155,8 @@ export class WebSocketWeb5RpcClient extends WebSocketDwnRpcClient implements Web async sendDidRequest(_request: DidRpcRequest): Promise { throw new Error(`not implemented for transports [${this.transportProtocols.join(', ')}]`); } + + async getServerInfo(_dwnUrl: string): Promise { + throw new Error(`not implemented for transports [${this.transportProtocols.join(', ')}]`); + } } \ No newline at end of file diff --git a/packages/agent/tests/prototyping/clients/dwn-server-info-cache.spec.ts b/packages/agent/tests/prototyping/clients/dwn-server-info-cache.spec.ts new file mode 100644 index 000000000..d8c2b1733 --- /dev/null +++ b/packages/agent/tests/prototyping/clients/dwn-server-info-cache.spec.ts @@ -0,0 +1,119 @@ +import sinon from 'sinon'; + +import { expect } from 'chai'; + +import { DwnServerInfoCache, ServerInfo } from '../../../src/prototyping/clients/server-info-types.js'; +import { DwnServerInfoCacheMemory } from '../../../src/prototyping/clients/dwn-server-info-cache-memory.js'; +import { isNode } from '../../utils/runtimes.js'; + +describe('DwnServerInfoCache', () => { + + describe(`DwnServerInfoCacheMemory`, () => { + let cache: DwnServerInfoCache; + let clock: sinon.SinonFakeTimers; + + const exampleInfo:ServerInfo = { + maxFileSize : 100, + webSocketSupport : true, + registrationRequirements : [] + }; + + after(() => { + sinon.restore(); + }); + + beforeEach(() => { + clock = sinon.useFakeTimers(); + cache = new DwnServerInfoCacheMemory(); + }); + + afterEach(async () => { + await cache.clear(); + await cache.close(); + clock.restore(); + }); + + it('sets server info in cache', async () => { + const key1 = 'some-key1'; + const key2 = 'some-key2'; + await cache.set(key1, { ...exampleInfo }); + await cache.set(key2, { ...exampleInfo, webSocketSupport: false }); // set to false + + const result1 = await cache.get(key1); + expect(result1!.webSocketSupport).to.deep.equal(true); + expect(result1).to.deep.equal(exampleInfo); + + const result2 = await cache.get(key2); + expect(result2!.webSocketSupport).to.deep.equal(false); + }); + + it('deletes from cache', async () => { + const key1 = 'some-key1'; + const key2 = 'some-key2'; + await cache.set(key1, { ...exampleInfo }); + await cache.set(key2, { ...exampleInfo, webSocketSupport: false }); // set to false + + const result1 = await cache.get(key1); + expect(result1!.webSocketSupport).to.deep.equal(true); + expect(result1).to.deep.equal(exampleInfo); + + const result2 = await cache.get(key2); + expect(result2!.webSocketSupport).to.deep.equal(false); + + // delete one of the keys + await cache.delete(key1); + + // check results after delete + const resultAfterDelete = await cache.get(key1); + expect(resultAfterDelete).to.equal(undefined); + + // key 2 still exists + const result2AfterDelete = await cache.get(key2); + expect(result2AfterDelete!.webSocketSupport).to.equal(false); + }); + + it('clears cache', async () => { + const key1 = 'some-key1'; + const key2 = 'some-key2'; + await cache.set(key1, { ...exampleInfo }); + await cache.set(key2, { ...exampleInfo, webSocketSupport: false }); // set to false + + const result1 = await cache.get(key1); + expect(result1!.webSocketSupport).to.deep.equal(true); + expect(result1).to.deep.equal(exampleInfo); + + const result2 = await cache.get(key2); + expect(result2!.webSocketSupport).to.deep.equal(false); + + // delete one of the keys + await cache.clear(); + + // check results after delete + const resultAfterDelete = await cache.get(key1); + expect(resultAfterDelete).to.equal(undefined); + const result2AfterDelete = await cache.get(key2); + expect(result2AfterDelete).to.equal(undefined); + }); + + it('returns undefined after ttl', async function () { + // skip this test in the browser, sinon fake timers don't seem to work here + // with a an await setTimeout in the test, it passes. + if (!isNode) { + this.skip(); + } + + const key = 'some-key1'; + await cache.set(key, { ...exampleInfo }); + + const result = await cache.get(key); + expect(result!.webSocketSupport).to.deep.equal(true); + expect(result).to.deep.equal(exampleInfo); + + // wait until 15m default ttl is up + await clock.tickAsync('15:01'); + + const resultAfter = await cache.get(key); + expect(resultAfter).to.be.undefined; + }); + }); +}); \ No newline at end of file diff --git a/packages/agent/tests/rpc-client.spec.ts b/packages/agent/tests/rpc-client.spec.ts index 11cc6c809..e26316c78 100644 --- a/packages/agent/tests/rpc-client.spec.ts +++ b/packages/agent/tests/rpc-client.spec.ts @@ -1,17 +1,65 @@ - import sinon from 'sinon'; - import { expect } from 'chai'; - -import { utils as cryptoUtils } from '@web5/crypto'; - import { testDwnUrl } from './utils/test-config.js'; +import { utils as cryptoUtils } from '@web5/crypto'; import { DidRpcMethod, HttpWeb5RpcClient, Web5RpcClient, WebSocketWeb5RpcClient } from '../src/rpc-client.js'; +import { DwnServerInfoCacheMemory } from '../src/prototyping/clients/dwn-server-info-cache-memory.js'; +import { HttpDwnRpcClient } from '../src/prototyping/clients/http-dwn-rpc-client.js'; import { Persona, TestDataGenerator } from '@tbd54566975/dwn-sdk-js'; import { JsonRpcErrorCodes, createJsonRpcErrorResponse, createJsonRpcSuccessResponse } from '../src/prototyping/clients/json-rpc.js'; describe('RPC Clients', () => { + describe('HttpDwnRpcClient', () => { + let client: HttpDwnRpcClient; + + beforeEach(async () => { + sinon.restore(); + client = new HttpDwnRpcClient(); + }); + + after(() => { + sinon.restore(); + }); + + it('should retrieve subsequent result from cache', async () => { + // we spy on fetch to see how many times it is called + const fetchSpy = sinon.spy(globalThis, 'fetch'); + + // fetch info first, currently not in cache should call fetch + const serverInfo = await client.getServerInfo(testDwnUrl); + expect(fetchSpy.callCount).to.equal(1); + + // confirm it exists in cache + const cachedResult = await client['serverInfoCache'].get(testDwnUrl); + expect(cachedResult).to.equal(serverInfo); + + // make another call and confirm that fetch ahs not been called again + const serverInfo2 = await client.getServerInfo(testDwnUrl); + expect(fetchSpy.callCount).to.equal(1); // should still equal only 1 + expect(cachedResult).to.equal(serverInfo2); + + // delete the cache entry to force a fetch call + await client['serverInfoCache'].delete(testDwnUrl); + const noResult = await client['serverInfoCache'].get(testDwnUrl); + expect(noResult).to.equal(undefined); + + // make a third call and confirm that a new fetch request was made and data is in the cache + const serverInfo3 = await client.getServerInfo(testDwnUrl); + expect(fetchSpy.callCount).to.equal(2); // another fetch call was made + const cachedResult2 = await client['serverInfoCache'].get(testDwnUrl); + expect(cachedResult2).to.equal(serverInfo3); + }); + + it('should accept an override server info cache', async () => { + const serverInfoCacheStub = sinon.createStubInstance(DwnServerInfoCacheMemory); + const client = new HttpDwnRpcClient(serverInfoCacheStub); + await client.getServerInfo(testDwnUrl); + + expect(serverInfoCacheStub.get.callCount).to.equal(1); + }); + }); + describe('Web5RpcClient', () => { let alice: Persona; @@ -115,6 +163,79 @@ describe('RPC Clients', () => { expect(stubHttpClient.sendDwnRequest.callCount).to.equal(0); }); }); + + describe('getServerInfo',() => { + let client: Web5RpcClient; + + after(() => { + sinon.restore(); + }); + + beforeEach(async () => { + sinon.restore(); + client = new Web5RpcClient(); + }); + + it('is able to get server info', async () => { + const serverInfo = await client.getServerInfo(testDwnUrl); + expect(serverInfo.registrationRequirements).to.not.be.undefined; + expect(serverInfo.maxFileSize).to.not.be.undefined; + expect(serverInfo.webSocketSupport).to.not.be.undefined; + }); + + it('throws for an invalid response', async () => { + const mockResponse = new Response(JSON.stringify({}), { status: 500 }); + sinon.stub(globalThis, 'fetch').resolves(mockResponse); + + try { + await client.getServerInfo(testDwnUrl); + expect.fail('Expected an error to be thrown'); + } catch(error: any) { + expect(error.message).to.contain('HTTP (500)'); + } + }); + + it('should append url with info path accounting for trailing slash', async () => { + const fetchStub = sinon.stub(globalThis, 'fetch').resolves(new Response(JSON.stringify({ + registrationRequirements : [], + maxFileSize : 123, + webSocketSupport : false, + }))); + + await client.getServerInfo('http://some-domain.com/dwn'); // without trailing slash + let fetchUrl = fetchStub.args[0][0]; + expect(fetchUrl).to.equal('http://some-domain.com/dwn/info'); + + // we reset the fetch stub and initiate a new response + // this wa the response body stream won't be attempt to be read twice and fail on the 2nd attempt. + fetchStub.reset(); + fetchStub.resolves(new Response(JSON.stringify({ + registrationRequirements : [], + maxFileSize : 123, + webSocketSupport : false, + }))); + + await client.getServerInfo('http://some-other-domain.com/dwn/'); // with trailing slash + fetchUrl = fetchStub.args[0][0]; + expect(fetchUrl).to.equal('http://some-other-domain.com/dwn/info'); + }); + + it('should throw if transport client is not found', async () => { + const stubHttpClient = sinon.createStubInstance(HttpWeb5RpcClient); + const httpOnlyClient = new Web5RpcClient([ stubHttpClient ]); + + // request with http + try { + await httpOnlyClient.getServerInfo('ws://127.0.0.1'); + expect.fail('Expected error to be thrown'); + } catch (error: any) { + expect(error.message).to.equal('no ws: transport client available'); + } + + // confirm http transport was not called + expect(stubHttpClient.sendDidRequest.callCount).to.equal(0); + }); + }); }); describe('HttpWeb5RpcClient', () => { @@ -252,5 +373,16 @@ describe('RPC Clients', () => { } }); }); + + describe('getServerInfo', () => { + it('server info requests are not supported over sockets', async () => { + try { + await client.getServerInfo(socketDwnUrl); + expect.fail('Expected error to be thrown'); + } catch (error: any) { + expect(error.message).to.equal('not implemented for transports [ws:, wss:]'); + } + }); + }); }); -}); \ No newline at end of file +});