diff --git a/.changeset/fair-pillows-notice.md b/.changeset/fair-pillows-notice.md new file mode 100644 index 000000000..796372911 --- /dev/null +++ b/.changeset/fair-pillows-notice.md @@ -0,0 +1,9 @@ +--- +"@web5/agent": patch +"@web5/dids": patch +"@web5/identity-agent": patch +"@web5/proxy-agent": patch +"@web5/user-agent": patch +--- + +Add ability to update DWN Endpoints diff --git a/packages/agent/src/bearer-identity.ts b/packages/agent/src/bearer-identity.ts index 8221f3035..41949909d 100644 --- a/packages/agent/src/bearer-identity.ts +++ b/packages/agent/src/bearer-identity.ts @@ -36,7 +36,7 @@ export class BearerIdentity { public async export(): Promise { return { portableDid : await this.did.export(), - metadata : this.metadata + metadata : { ...this.metadata }, }; } } \ No newline at end of file diff --git a/packages/agent/src/identity-api.ts b/packages/agent/src/identity-api.ts index 113f35e9c..f35bab22a 100644 --- a/packages/agent/src/identity-api.ts +++ b/packages/agent/src/identity-api.ts @@ -9,6 +9,8 @@ import type { IdentityMetadata, PortableIdentity } from './types/identity.js'; import { BearerIdentity } from './bearer-identity.js'; import { isPortableDid } from './prototyping/dids/utils.js'; import { InMemoryIdentityStore } from './store-identity.js'; +import { getDwnServiceEndpointUrls } from './utils.js'; +import { PortableDid } from '@web5/dids'; export interface IdentityApiParams { agent?: Web5PlatformAgent; @@ -216,6 +218,58 @@ export class AgentIdentityApi { + return getDwnServiceEndpointUrls(didUri, this.agent.did); + } + + /** + * Sets the DWN endpoints for the given DID. + * + * @param didUri - The DID URI to set the DWN endpoints for. + * @param endpoints - The array of DWN endpoints to set. + * @throws An error if the DID is not found, or if an update cannot be performed. + */ + public async setDwnEndpoints({ didUri, endpoints }: { didUri: string; endpoints: string[] }): Promise { + const bearerDid = await this.agent.did.get({ didUri }); + if (!bearerDid) { + throw new Error(`AgentIdentityApi: Failed to set DWN endpoints due to DID not found: ${didUri}`); + } + + const portableDid = await bearerDid.export(); + const dwnService = portableDid.document.service?.find(service => service.id.endsWith('dwn')); + if (dwnService) { + // Update the existing DWN Service with the provided endpoints + dwnService.serviceEndpoint = endpoints; + } else { + + // create a DWN Service to add to the DID document + const newDwnService = { + id : 'dwn', + type : 'DecentralizedWebNode', + serviceEndpoint : endpoints, + enc : '#enc', + sig : '#sig' + }; + + // if no other services exist, create a new array with the DWN service + if (!portableDid.document.service) { + portableDid.document.service = [newDwnService]; + } else { + // otherwise, push the new DWN service to the existing services + portableDid.document.service.push(newDwnService); + } + } + + await this.agent.did.update({ portableDid, tenant: this.agent.agentDid.uri }); + } + /** * Returns the connected Identity, if one is available. * diff --git a/packages/agent/src/utils.ts b/packages/agent/src/utils.ts index f0d1824aa..2ab38757f 100644 --- a/packages/agent/src/utils.ts +++ b/packages/agent/src/utils.ts @@ -4,7 +4,7 @@ import { PaginationCursor, RecordsDeleteMessage, RecordsWriteMessage } from '@tb import { Readable } from '@web5/common'; import { utils as didUtils } from '@web5/dids'; import { ReadableWebToNodeStream } from 'readable-web-to-node-stream'; -import { DateSort, DwnInterfaceName, DwnMethodName, Message, Records, RecordsWrite } from '@tbd54566975/dwn-sdk-js'; +import { DateSort, DwnInterfaceName, DwnMethodName, Message, RecordsWrite } from '@tbd54566975/dwn-sdk-js'; export function blobToIsomorphicNodeReadable(blob: Blob): Readable { return webReadableToIsomorphicNodeReadable(blob.stream() as ReadableStream); diff --git a/packages/agent/tests/identity-api.spec.ts b/packages/agent/tests/identity-api.spec.ts index b143a17db..3ce0bcff2 100644 --- a/packages/agent/tests/identity-api.spec.ts +++ b/packages/agent/tests/identity-api.spec.ts @@ -5,6 +5,7 @@ import { TestAgent } from './utils/test-agent.js'; import { AgentIdentityApi } from '../src/identity-api.js'; import { PlatformAgentTestHarness } from '../src/test-harness.js'; import { PortableIdentity } from '../src/index.js'; +import { BearerDid, PortableDid, UniversalResolver } from '@web5/dids'; describe('AgentIdentityApi', () => { @@ -220,6 +221,225 @@ describe('AgentIdentityApi', () => { }); }); + describe('setDwnEndpoints()', () => { + const testPortableDid: PortableDid = { + uri : 'did:dht:d71hju6wjeu5j7r5sbujqkubktds1kbtei8imkj859jr4hw77hdy', + document : { + id : 'did:dht:d71hju6wjeu5j7r5sbujqkubktds1kbtei8imkj859jr4hw77hdy', + verificationMethod : [ + { + id : 'did:dht:d71hju6wjeu5j7r5sbujqkubktds1kbtei8imkj859jr4hw77hdy#0', + type : 'JsonWebKey', + controller : 'did:dht:d71hju6wjeu5j7r5sbujqkubktds1kbtei8imkj859jr4hw77hdy', + publicKeyJwk : { + crv : 'Ed25519', + kty : 'OKP', + x : 'H2XEz9RKJ7T0m7BmlyphVEdpKDFFT1WpJ9_STXKd7wY', + kid : '-2bXX6F3hvTHV5EBFX6oyKq11s7gtJdzUjjwdeUyBVA', + alg : 'EdDSA' + } + }, + { + id : 'did:dht:d71hju6wjeu5j7r5sbujqkubktds1kbtei8imkj859jr4hw77hdy#sig', + type : 'JsonWebKey', + controller : 'did:dht:d71hju6wjeu5j7r5sbujqkubktds1kbtei8imkj859jr4hw77hdy', + publicKeyJwk : { + crv : 'Ed25519', + kty : 'OKP', + x : 'T2rdfCxGubY_zta8Gy6SVxypcchfmZKJhbXB9Ia9xlg', + kid : 'Ogpmsy5VR3SET9WC0WZD9r5p1WAKdCt1fxT0GNSLE5c', + alg : 'EdDSA' + } + }, + { + id : 'did:dht:d71hju6wjeu5j7r5sbujqkubktds1kbtei8imkj859jr4hw77hdy#enc', + type : 'JsonWebKey', + controller : 'did:dht:d71hju6wjeu5j7r5sbujqkubktds1kbtei8imkj859jr4hw77hdy', + publicKeyJwk : { + kty : 'EC', + crv : 'secp256k1', + x : 'oTPWtNfN7e48p3n-VsoSp07kcHfCszSrJ1-qFx3diiI', + y : '5KSDrAkg91yK19zxD6ESRPAI8v91F-QRXPbivZ-v-Ac', + kid : 'K0CBI00sEmYE6Av4PHqiwPNMzrBRA9dyIlzh1a9A2H8', + alg : 'ES256K' + } + } + ], + authentication: [ + 'did:dht:d71hju6wjeu5j7r5sbujqkubktds1kbtei8imkj859jr4hw77hdy#0', + 'did:dht:d71hju6wjeu5j7r5sbujqkubktds1kbtei8imkj859jr4hw77hdy#sig' + ], + assertionMethod: [ + 'did:dht:d71hju6wjeu5j7r5sbujqkubktds1kbtei8imkj859jr4hw77hdy#0', + 'did:dht:d71hju6wjeu5j7r5sbujqkubktds1kbtei8imkj859jr4hw77hdy#sig' + ], + capabilityDelegation: [ + 'did:dht:d71hju6wjeu5j7r5sbujqkubktds1kbtei8imkj859jr4hw77hdy#0' + ], + capabilityInvocation: [ + 'did:dht:d71hju6wjeu5j7r5sbujqkubktds1kbtei8imkj859jr4hw77hdy#0' + ], + keyAgreement: [ + 'did:dht:d71hju6wjeu5j7r5sbujqkubktds1kbtei8imkj859jr4hw77hdy#enc' + ], + service: [ + { + id : 'did:dht:d71hju6wjeu5j7r5sbujqkubktds1kbtei8imkj859jr4hw77hdy#dwn', + type : 'DecentralizedWebNode', + serviceEndpoint : [ + 'https://example.com/dwn' + ], + enc : '#enc', + sig : '#sig' + } + ] + }, + metadata: { + published : true, + versionId : '1729109527' + }, + privateKeys: [ + { + crv : 'Ed25519', + d : '7vRkinnXFRb2GkNVeY5yQ6TCnYwbtq9gJcbdqnzFR2o', + kty : 'OKP', + x : 'H2XEz9RKJ7T0m7BmlyphVEdpKDFFT1WpJ9_STXKd7wY', + kid : '-2bXX6F3hvTHV5EBFX6oyKq11s7gtJdzUjjwdeUyBVA', + alg : 'EdDSA' + }, + { + crv : 'Ed25519', + d : 'YM-0lQkMc9mNr2NrBVMojpCG2MMAnYk6-4dwxlFeiuw', + kty : 'OKP', + x : 'T2rdfCxGubY_zta8Gy6SVxypcchfmZKJhbXB9Ia9xlg', + kid : 'Ogpmsy5VR3SET9WC0WZD9r5p1WAKdCt1fxT0GNSLE5c', + alg : 'EdDSA' + }, + { + kty : 'EC', + crv : 'secp256k1', + d : 'f4BngIzc_N-YDf04vXD5Ya-HdiVWB8Egk4QoSHKKJPg', + x : 'oTPWtNfN7e48p3n-VsoSp07kcHfCszSrJ1-qFx3diiI', + y : '5KSDrAkg91yK19zxD6ESRPAI8v91F-QRXPbivZ-v-Ac', + kid : 'K0CBI00sEmYE6Av4PHqiwPNMzrBRA9dyIlzh1a9A2H8', + alg : 'ES256K' + } + ] + }; + + beforeEach(async () => { + // import the keys for the test portable DID + await BearerDid.import({ keyManager: testHarness.agent.keyManager, portableDid: testPortableDid }); + }); + + it('should set the DWN endpoints for a DID', async () => { + // stub did.get to return the test DID + sinon.stub(testHarness.agent.did, 'get').resolves(new BearerDid({ ...testPortableDid, keyManager: testHarness.agent.keyManager })); + const updateSpy = sinon.stub(testHarness.agent.did, 'update').resolves(); + + // set new endpoints + const newEndpoints = ['https://example.com/dwn2']; + await testHarness.agent.identity.setDwnEndpoints({ didUri: testPortableDid.uri, endpoints: newEndpoints }); + + expect(updateSpy.calledOnce).to.be.true; + // expect the updated DID to have the new DWN service + expect(updateSpy.firstCall.args[0].portableDid.document.service).to.deep.equal([{ + id : `${testPortableDid.uri}#dwn`, + type : 'DecentralizedWebNode', + serviceEndpoint : newEndpoints, + enc : '#enc', + sig : '#sig' + }]); + }); + + it('should throw an error if the service endpoints remain unchanged', async () => { + // stub did.get to return the test DID + sinon.stub(testHarness.agent.did, 'get').resolves(new BearerDid({ ...testPortableDid, keyManager: testHarness.agent.keyManager })); + + // set the same endpoints + try { + await testHarness.agent.identity.setDwnEndpoints({ didUri: testPortableDid.uri, endpoints: ['https://example.com/dwn'] }); + expect.fail('Expected an error to be thrown'); + } catch (error: any) { + expect(error.message).to.include('AgentDidApi: No changes detected'); + } + }); + + it('should throw an error if the DID is not found', async () => { + try { + await testHarness.agent.identity.setDwnEndpoints({ didUri: 'did:method:xyz123', endpoints: ['https://example.com/dwn'] }); + expect.fail('Expected an error to be thrown'); + } catch (error: any) { + expect(error.message).to.include('AgentIdentityApi: Failed to set DWN endpoints due to DID not found'); + } + }); + + it('should add a DWN service if no services exist', async () => { + // stub the did.get to return a DID without any services + const testPortableDidWithoutServices = { ...testPortableDid, document: { ...testPortableDid.document, service: undefined } }; + sinon.stub(testHarness.agent.did, 'get').resolves(new BearerDid({ ...testPortableDidWithoutServices, keyManager: testHarness.agent.keyManager })); + sinon.stub(UniversalResolver.prototype, 'resolve').withArgs(testPortableDid.uri).resolves({ didDocument: testPortableDidWithoutServices.document, didDocumentMetadata: {}, didResolutionMetadata: {} }); + const updateSpy = sinon.stub(testHarness.agent.did, 'update').resolves(); + + // control: get the service endpoints of the created DID, should fail + try { + await testHarness.agent.identity.getDwnEndpoints({ didUri: testPortableDid.uri }); + expect.fail('should have thrown an error'); + } catch(error: any) { + expect(error.message).to.include('Failed to dereference'); + } + + // set new endpoints + const newEndpoints = ['https://example.com/dwn2']; + await testHarness.agent.identity.setDwnEndpoints({ didUri: testPortableDid.uri, endpoints: newEndpoints }); + + expect(updateSpy.calledOnce).to.be.true; + + // expect the updated DID to have the new DWN service + expect(updateSpy.firstCall.args[0].portableDid.document.service).to.deep.equal([{ + id : 'dwn', + type : 'DecentralizedWebNode', + serviceEndpoint : newEndpoints, + enc : '#enc', + sig : '#sig' + }]); + }); + + it('should add a DWN service if one does not exist in the services list', async () => { + // stub the did.get and resolver to return a DID with a different service + const testPortableDidWithDifferentService = { ...testPortableDid, document: { ...testPortableDid.document, service: [{ id: 'other', type: 'Other', serviceEndpoint: ['https://example.com/other'] }] } }; + sinon.stub(testHarness.agent.did, 'get').resolves(new BearerDid({ ...testPortableDidWithDifferentService, keyManager: testHarness.agent.keyManager })); + sinon.stub(UniversalResolver.prototype, 'resolve').withArgs(testPortableDid.uri).resolves({ didDocument: testPortableDidWithDifferentService.document, didDocumentMetadata: {}, didResolutionMetadata: {} }); + const updateSpy = sinon.stub(testHarness.agent.did, 'update').resolves(); + + // control: get the service endpoints of the created DID, should fail + try { + await testHarness.agent.identity.getDwnEndpoints({ didUri: testPortableDidWithDifferentService.uri }); + expect.fail('should have thrown an error'); + } catch(error: any) { + expect(error.message).to.include('Failed to dereference'); + } + + // set new endpoints + const newEndpoints = ['https://example.com/dwn2']; + await testHarness.agent.identity.setDwnEndpoints({ didUri: testPortableDidWithDifferentService.uri, endpoints: newEndpoints }); + + // expect the updated DID to have the new DWN service as well as the existing service + expect(updateSpy.calledOnce).to.be.true; + expect(updateSpy.firstCall.args[0].portableDid.document.service).to.deep.equal([{ + id : 'other', + type : 'Other', + serviceEndpoint : ['https://example.com/other'] + }, { + id : 'dwn', + type : 'DecentralizedWebNode', + serviceEndpoint : newEndpoints, + enc : '#enc', + sig : '#sig' + }]); + }); + }); + describe('connectedIdentity', () => { it('returns a connected Identity', async () => { // create multiple identities, some that are connected, and some that are not diff --git a/packages/dids/src/bearer-did.ts b/packages/dids/src/bearer-did.ts index cdecee834..4ed3dbd58 100644 --- a/packages/dids/src/bearer-did.ts +++ b/packages/dids/src/bearer-did.ts @@ -128,12 +128,12 @@ export class BearerDid { throw new Error(`DID document for '${this.uri}' is missing verification methods`); } - // Create a new `PortableDid` object to store the exported data. - let portableDid: PortableDid = { + // Create a new `PortableDid` copy object to store the exported data. + let portableDid: PortableDid = JSON.parse(JSON.stringify({ uri : this.uri, document : this.document, metadata : this.metadata - }; + })); // If the BearerDid's key manager supports exporting private keys, add them to the portable DID. if ('exportKey' in this.keyManager && typeof this.keyManager.exportKey === 'function') {