Skip to content

Commit

Permalink
Ability to get and set DWN Service Endpoints from the IdentityAPI (#953)
Browse files Browse the repository at this point in the history
This PR adds the ability to set new DWN Service Endpoints

- add `setDwnEndpoints` method to `IdentityApi`
- add `getDwnEndpoints` helper to `IdenttyApi`
- ensure a deep copy of the DID is returned with `bearerDid.export()` to avoid side-effects
  • Loading branch information
LiranCohen authored Oct 17, 2024
1 parent bd1cb00 commit 3f39bf1
Show file tree
Hide file tree
Showing 6 changed files with 288 additions and 5 deletions.
9 changes: 9 additions & 0 deletions .changeset/fair-pillows-notice.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion packages/agent/src/bearer-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export class BearerIdentity {
public async export(): Promise<PortableIdentity> {
return {
portableDid : await this.did.export(),
metadata : this.metadata
metadata : { ...this.metadata },
};
}
}
54 changes: 54 additions & 0 deletions packages/agent/src/identity-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TKeyManager extends AgentKeyManager> {
agent?: Web5PlatformAgent<TKeyManager>;
Expand Down Expand Up @@ -216,6 +218,58 @@ export class AgentIdentityApi<TKeyManager extends AgentKeyManager = AgentKeyMana
await this._store.delete({ id: didUri, agent: this.agent });
}

/**
* Returns the DWN endpoints for the given DID.
*
* @param didUri - The DID URI to get the DWN endpoints for.
* @returns An array of DWN endpoints.
* @throws An error if the DID is not found, or no DWN service exists.
*/
public getDwnEndpoints({ didUri }: { didUri: string; }): Promise<string[]> {
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<void> {
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.
*
Expand Down
2 changes: 1 addition & 1 deletion packages/agent/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>);
Expand Down
220 changes: 220 additions & 0 deletions packages/agent/tests/identity-api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {

Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions packages/dids/src/bearer-did.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down

0 comments on commit 3f39bf1

Please sign in to comment.