From 5467ca06a0e6e3592a364bc385dd074c79b72ee2 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Thu, 26 Sep 2024 18:03:17 -0400 Subject: [PATCH] agent resolver refreshes did store and cache --- .../agent/src/agent-did-resolver-cache.ts | 13 ++++- packages/agent/src/did-api.ts | 56 ++++++++++++++++++- packages/agent/src/store-data.ts | 31 ++++++++-- .../tests/agent-did-resolver-cach.spec.ts | 5 +- packages/agent/tests/store-data.spec.ts | 52 +++++++++++++++++ 5 files changed, 144 insertions(+), 13 deletions(-) diff --git a/packages/agent/src/agent-did-resolver-cache.ts b/packages/agent/src/agent-did-resolver-cache.ts index ecf35b76b..b6febc0b4 100644 --- a/packages/agent/src/agent-did-resolver-cache.ts +++ b/packages/agent/src/agent-did-resolver-cache.ts @@ -47,11 +47,18 @@ export class AgentDidResolverCache extends DidResolverCacheLevel implements DidR const cachedResult = JSON.parse(str); if (!this._resolving.has(did) && Date.now() >= cachedResult.ttlMillis) { this._resolving.set(did, true); - if (this.agent.agentDid.uri === did || 'undefined' !== typeof await this.agent.identity.get({ didUri: did })) { + + const storedDid = await this.agent.did.get({ didUri: did, tenant: this.agent.agentDid.uri }); + if ('undefined' !== typeof storedDid) { try { const result = await this.agent.did.resolve(did); - if (!result.didResolutionMetadata.error) { - this.set(did, result); + if (!result.didResolutionMetadata.error && result.didDocument) { + const portableDid = { + ...storedDid, + document : result.didDocument, + metadata : result.didDocumentMetadata, + }; + await this.agent.did.update({ portableDid, tenant: this.agent.agentDid.uri }); } } finally { this._resolving.delete(did); diff --git a/packages/agent/src/did-api.ts b/packages/agent/src/did-api.ts index 91abb5807..8b2566402 100644 --- a/packages/agent/src/did-api.ts +++ b/packages/agent/src/did-api.ts @@ -11,7 +11,7 @@ import type { DidResolverCache, } from '@web5/dids'; -import { BearerDid, Did, UniversalResolver } from '@web5/dids'; +import { BearerDid, Did, DidDht, UniversalResolver } from '@web5/dids'; import type { AgentDataStore } from './store-data.js'; import type { AgentKeyManager } from './types/key-manager.js'; @@ -19,6 +19,7 @@ import type { ResponseStatus, Web5PlatformAgent } from './types/agent.js'; import { InMemoryDidStore } from './store-did.js'; import { AgentDidResolverCache } from './agent-did-resolver-cache.js'; +import { canonicalize } from '@web5/crypto'; export enum DidInterface { Create = 'Create', @@ -256,6 +257,59 @@ export class AgentDidApi return verificationMethod; } + public async update({ tenant, portableDid, publish = true }: { + tenant?: string; + portableDid: PortableDid; + publish?: boolean; + }): Promise { + + // Check if the DID exists in the store. + const existingDid = await this.get({ didUri: portableDid.uri, tenant }); + if (!existingDid) { + throw new Error(`AgentDidApi: Could not update, DID not found: ${portableDid.uri}`); + } + + // If the document has not changed, abort the update. + if (canonicalize(portableDid.document) === canonicalize(existingDid.document)) { + throw new Error('AgentDidApi: No changes detected, update aborted'); + } + + // If private keys are present in the PortableDid, import the key material into the Agent's key + // manager. Validate that the key material for every verification method in the DID document is + // present in the key manager. + const bearerDid = await BearerDid.import({ keyManager: this.agent.keyManager, portableDid }); + + // Only the DID URI, document, and metadata are stored in the Agent's DID store. + const { uri, document, metadata } = bearerDid; + const portableDidWithoutKeys: PortableDid = { uri, document, metadata }; + + // pre-populate the resolution cache with the document and metadata + await this.cache.set(uri, { didDocument: document, didResolutionMetadata: { }, didDocumentMetadata: metadata }); + + // Store the DID in the agent's DID store. + // Unless an existing `tenant` is specified, a record that includes the DID's URI, document, + // and metadata will be stored under a new tenant controlled by the imported DID. + await this._store.set({ + id : portableDidWithoutKeys.uri, + data : portableDidWithoutKeys, + agent : this.agent, + tenant : tenant ?? portableDidWithoutKeys.uri, + updateExisting : true, + useCache : true + }); + + if (publish) { + const parsedDid = Did.parse(bearerDid.uri); + // currently only supporting DHT as a publishable method. + // TODO: abstract this into the didMethod class so that other publishable methods can be supported. + if (parsedDid && parsedDid.method === 'dht') { + await DidDht.publish({ did: bearerDid }); + } + } + + return bearerDid; + } + public async import({ portableDid, tenant }: { portableDid: PortableDid; tenant?: string; diff --git a/packages/agent/src/store-data.ts b/packages/agent/src/store-data.ts index 7d5699022..1709c2b59 100644 --- a/packages/agent/src/store-data.ts +++ b/packages/agent/src/store-data.ts @@ -7,7 +7,7 @@ import type { Web5PlatformAgent } from './types/agent.js'; import { TENANT_SEPARATOR } from './utils-internal.js'; import { getDataStoreTenant } from './utils-internal.js'; -import { DwnInterface } from './types/dwn.js'; +import { DwnInterface, DwnMessageParams } from './types/dwn.js'; import { ProtocolDefinition } from '@tbd54566975/dwn-sdk-js'; export type DataStoreTenantParams = { @@ -26,6 +26,7 @@ export type DataStoreSetParams = DataStoreTenantParams & { id: string; data: TStoreObject; preventDuplicates?: boolean; + updateExisting?: boolean; useCache?: boolean; } @@ -137,7 +138,7 @@ export class DwnDataStore = Jwk> implem return storedRecords; } - public async set({ id, data, tenant, agent, preventDuplicates = true, useCache = false }: + public async set({ id, data, tenant, agent, preventDuplicates = true, updateExisting = false, useCache = false }: DataStoreSetParams ): Promise { // Determine the tenant identifier (DID) for the set operation. @@ -146,8 +147,18 @@ export class DwnDataStore = Jwk> implem // initialize the storage protocol if not already done await this.initialize({ tenant: tenantDid, agent }); - // If enabled, check if a record with the given `id` is already present in the store. - if (preventDuplicates) { + const messageParams: DwnMessageParams[DwnInterface.RecordsWrite] = { ...this._recordProperties }; + + if (updateExisting) { + // Look up the DWN record ID of the object in the store with the given `id`. + const matchingRecordId = await this.lookupRecordId({ id, tenantDid, agent }); + if (!matchingRecordId) { + throw new Error(`${this.name}: Update failed due to missing entry for: ${id}`); + } + + // set the recordId in the messageParams to update the existing record + messageParams.recordId = matchingRecordId; + } else if (preventDuplicates) { // Look up the DWN record ID of the object in the store with the given `id`. const matchingRecordId = await this.lookupRecordId({ id, tenantDid, agent }); if (matchingRecordId) { @@ -155,6 +166,7 @@ export class DwnDataStore = Jwk> implem } } + // Convert the store object to a byte array, which will be the data payload of the DWN record. const dataBytes = Convert.object(data).toUint8Array(); @@ -340,12 +352,19 @@ export class InMemoryDataStore = Jwk> i return result; } - public async set({ id, data, tenant, agent, preventDuplicates }: DataStoreSetParams): Promise { + public async set({ id, data, tenant, agent, preventDuplicates, updateExisting }: DataStoreSetParams): Promise { // Determine the tenant identifier (DID) for the set operation. const tenantDid = await getDataStoreTenant({ agent, tenant, didUri: id }); // If enabled, check if a record with the given `id` is already present in the store. - if (preventDuplicates) { + if (updateExisting) { + // Look up the DWN record ID of the object in the store with the given `id`. + if (!this.store.has(`${tenantDid}${TENANT_SEPARATOR}${id}`)) { + throw new Error(`${this.name}: Update failed due to missing entry for: ${id}`); + } + + // set the recordId in the messageParams to update the existing record + } else if (preventDuplicates) { const duplicateFound = this.store.has(`${tenantDid}${TENANT_SEPARATOR}${id}`); if (duplicateFound) { throw new Error(`${this.name}: Import failed due to duplicate entry for: ${id}`); diff --git a/packages/agent/tests/agent-did-resolver-cach.spec.ts b/packages/agent/tests/agent-did-resolver-cach.spec.ts index 37b7536b0..84beeb306 100644 --- a/packages/agent/tests/agent-did-resolver-cach.spec.ts +++ b/packages/agent/tests/agent-did-resolver-cach.spec.ts @@ -61,11 +61,10 @@ describe('AgentDidResolverCache', () => { }); it('should not call resolve if the DID is not the agent DID or exists as an identity in the agent', async () => { - const did = await DidJwk.create({}); + const did = await DidJwk.create(); const getStub = sinon.stub(resolverCache['cache'], 'get').resolves(JSON.stringify({ ttlMillis: Date.now() - 1000, value: { didDocument: { id: did.uri } } })); - const resolveSpy = sinon.spy(testHarness.agent.did, 'resolve'); + const resolveSpy = sinon.spy(testHarness.agent.did, 'resolve').withArgs(did.uri); const nextTickSpy = sinon.stub(resolverCache['cache'], 'nextTick').resolves(); - sinon.stub(testHarness.agent.identity, 'get').resolves(undefined); await resolverCache.get(did.uri), diff --git a/packages/agent/tests/store-data.spec.ts b/packages/agent/tests/store-data.spec.ts index 8340a541b..ac562dac3 100644 --- a/packages/agent/tests/store-data.spec.ts +++ b/packages/agent/tests/store-data.spec.ts @@ -702,6 +702,58 @@ describe('AgentDataStore', () => { expect(error.message).to.include('Failed to install protocol: 500 - Internal Server Error'); } }); + + describe('updateExisting', () => { + it('updates an existing record', async () => { + // Create and import a DID. + let bearerDid = await DidJwk.create(); + const importedDid = await testHarness.agent.did.import({ + portableDid : await bearerDid.export(), + tenant : testHarness.agent.agentDid.uri + }); + + const portableDid = await importedDid.export(); + + // update did document's service + const updatedDid = { + ...portableDid, + document: { + ...portableDid.document, + service: [{ id: 'test-service', type: 'test-type', serviceEndpoint: 'test-endpoint' }] + } + }; + + // Update the DID in the store. + await testStore.set({ + id : importedDid.uri, + data : updatedDid, + agent : testHarness.agent, + updateExisting : true, + tenant : testHarness.agent.agentDid.uri + }); + + // Verify the DID is in the store. + const storedDid = await testStore.get({ id: importedDid.uri, agent: testHarness.agent, tenant: testHarness.agent.agentDid.uri }); + expect(storedDid!.uri).to.equal(updatedDid.uri); + expect(storedDid!.document).to.deep.equal(updatedDid.document); + }); + + it('throws an error if the record does not exist', async () => { + const did = await DidJwk.create(); + const portableDid = await did.export(); + try { + await testStore.set({ + id : portableDid.uri, + data : portableDid, + agent : testHarness.agent, + updateExisting : true + }); + expect.fail('Expected an error to be thrown'); + } catch (error: any) { + expect(error.message).to.include(`${TestStore.name}: Update failed due to missing entry for: ${portableDid.uri}`); + } + }); + }); }); }); });