Skip to content

Commit bd1cb00

Browse files
authored
Agent resolver refreshes did store and cache (#914)
This PR adds `update` functionality to `DidApi` which will store an updated did document and (optionally) publish the updated document if the did is a `did:dht` method, the resolution cache is pre-populated with the updated document. Additionally the `AgentDidResolverCache` now updates the DID Store with any newly resolved DID to make sure the locate store is in sync wit the resolved DID. The `BearerDid` import method now checks if a key already exists in the key manager before attempting to import it when importing an `portableDid`.
1 parent aaf4b4a commit bd1cb00

10 files changed

+474
-30
lines changed

.changeset/brave-cameras-reply.md

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@web5/agent": minor
3+
"@web5/dids": minor
4+
"@web5/identity-agent": minor
5+
"@web5/proxy-agent": minor
6+
"@web5/user-agent": minor
7+
---
8+
9+
Ability to Update a DID

packages/agent/src/agent-did-resolver-cache.ts

+26-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { DidResolutionResult, DidResolverCache, DidResolverCacheLevel, DidResolverCacheLevelParams } from '@web5/dids';
22
import { Web5PlatformAgent } from './types/agent.js';
3+
import { logger } from '@web5/common';
34

45

56
/**
@@ -47,11 +48,33 @@ export class AgentDidResolverCache extends DidResolverCacheLevel implements DidR
4748
const cachedResult = JSON.parse(str);
4849
if (!this._resolving.has(did) && Date.now() >= cachedResult.ttlMillis) {
4950
this._resolving.set(did, true);
50-
if (this.agent.agentDid.uri === did || 'undefined' !== typeof await this.agent.identity.get({ didUri: did })) {
51+
52+
// if a DID is stored in the DID Store, then we don't want to evict it from the cache until we have a successful resolution
53+
// upon a successful resolution, we will update both the storage and the cache with the newly resolved Document.
54+
const storedDid = await this.agent.did.get({ didUri: did, tenant: this.agent.agentDid.uri });
55+
if ('undefined' !== typeof storedDid) {
5156
try {
5257
const result = await this.agent.did.resolve(did);
53-
if (!result.didResolutionMetadata.error) {
54-
this.set(did, result);
58+
59+
// if the resolution was successful, update the stored DID with the new Document
60+
if (!result.didResolutionMetadata.error && result.didDocument) {
61+
62+
const portableDid = {
63+
...storedDid,
64+
document : result.didDocument,
65+
metadata : result.didDocumentMetadata,
66+
};
67+
68+
try {
69+
// this will throw an error if the DID is not managed by the agent, or there is no difference between the stored and resolved DID
70+
// We don't publish the DID in this case, as it was received by the resolver.
71+
await this.agent.did.update({ portableDid, tenant: this.agent.agentDid.uri, publish: false });
72+
} catch(error: any) {
73+
// if the error is not due to no changes detected, log the error
74+
if (error.message && !error.message.includes('No changes detected, update aborted')) {
75+
logger.error(`Error updating DID: ${error.message}`);
76+
}
77+
}
5578
}
5679
} finally {
5780
this._resolving.delete(did);

packages/agent/src/did-api.ts

+54-1
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,15 @@ import type {
1111
DidResolverCache,
1212
} from '@web5/dids';
1313

14-
import { BearerDid, Did, UniversalResolver } from '@web5/dids';
14+
import { BearerDid, Did, DidDht, UniversalResolver } from '@web5/dids';
1515

1616
import type { AgentDataStore } from './store-data.js';
1717
import type { AgentKeyManager } from './types/key-manager.js';
1818
import type { ResponseStatus, Web5PlatformAgent } from './types/agent.js';
1919

2020
import { InMemoryDidStore } from './store-did.js';
2121
import { AgentDidResolverCache } from './agent-did-resolver-cache.js';
22+
import { canonicalize } from '@web5/crypto';
2223

2324
export enum DidInterface {
2425
Create = 'Create',
@@ -256,6 +257,58 @@ export class AgentDidApi<TKeyManager extends AgentKeyManager = AgentKeyManager>
256257
return verificationMethod;
257258
}
258259

260+
public async update({ tenant, portableDid, publish = true }: {
261+
tenant?: string;
262+
portableDid: PortableDid;
263+
publish?: boolean;
264+
}): Promise<BearerDid> {
265+
266+
// Check if the DID exists in the store.
267+
const existingDid = await this.get({ didUri: portableDid.uri, tenant: tenant ?? portableDid.uri });
268+
if (!existingDid) {
269+
throw new Error(`AgentDidApi: Could not update, DID not found: ${portableDid.uri}`);
270+
}
271+
272+
// If the document has not changed, abort the update.
273+
if (canonicalize(portableDid.document) === canonicalize(existingDid.document)) {
274+
throw new Error('AgentDidApi: No changes detected, update aborted');
275+
}
276+
277+
// If private keys are present in the PortableDid, import the key material into the Agent's key
278+
// manager. Validate that the key material for every verification method in the DID document is
279+
// present in the key manager. If no keys are present, this will fail.
280+
// NOTE: We currently do not delete the previous keys from the document.
281+
// TODO: Add support for deleting the keys no longer present in the document.
282+
const bearerDid = await BearerDid.import({ keyManager: this.agent.keyManager, portableDid });
283+
284+
// Only the DID URI, document, and metadata are stored in the Agent's DID store.
285+
const { uri, document, metadata } = bearerDid;
286+
const portableDidWithoutKeys: PortableDid = { uri, document, metadata };
287+
288+
// pre-populate the resolution cache with the document and metadata
289+
await this.cache.set(uri, { didDocument: document, didResolutionMetadata: { }, didDocumentMetadata: metadata });
290+
291+
await this._store.set({
292+
id : uri,
293+
data : portableDidWithoutKeys,
294+
agent : this.agent,
295+
tenant : tenant ?? uri,
296+
updateExisting : true,
297+
useCache : true
298+
});
299+
300+
if (publish) {
301+
const parsedDid = Did.parse(uri);
302+
// currently only supporting DHT as a publishable method.
303+
// TODO: abstract this into the didMethod class so that other publishable methods can be supported.
304+
if (parsedDid && parsedDid.method === 'dht') {
305+
await DidDht.publish({ did: bearerDid });
306+
}
307+
}
308+
309+
return bearerDid;
310+
}
311+
259312
public async import({ portableDid, tenant }: {
260313
portableDid: PortableDid;
261314
tenant?: string;

packages/agent/src/store-data.ts

+25-6
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type { Web5PlatformAgent } from './types/agent.js';
77

88
import { TENANT_SEPARATOR } from './utils-internal.js';
99
import { getDataStoreTenant } from './utils-internal.js';
10-
import { DwnInterface } from './types/dwn.js';
10+
import { DwnInterface, DwnMessageParams } from './types/dwn.js';
1111
import { ProtocolDefinition } from '@tbd54566975/dwn-sdk-js';
1212

1313
export type DataStoreTenantParams = {
@@ -26,6 +26,7 @@ export type DataStoreSetParams<TStoreObject> = DataStoreTenantParams & {
2626
id: string;
2727
data: TStoreObject;
2828
preventDuplicates?: boolean;
29+
updateExisting?: boolean;
2930
useCache?: boolean;
3031
}
3132

@@ -137,7 +138,7 @@ export class DwnDataStore<TStoreObject extends Record<string, any> = Jwk> implem
137138
return storedRecords;
138139
}
139140

140-
public async set({ id, data, tenant, agent, preventDuplicates = true, useCache = false }:
141+
public async set({ id, data, tenant, agent, preventDuplicates = true, updateExisting = false, useCache = false }:
141142
DataStoreSetParams<TStoreObject>
142143
): Promise<void> {
143144
// Determine the tenant identifier (DID) for the set operation.
@@ -146,15 +147,26 @@ export class DwnDataStore<TStoreObject extends Record<string, any> = Jwk> implem
146147
// initialize the storage protocol if not already done
147148
await this.initialize({ tenant: tenantDid, agent });
148149

149-
// If enabled, check if a record with the given `id` is already present in the store.
150-
if (preventDuplicates) {
150+
const messageParams: DwnMessageParams[DwnInterface.RecordsWrite] = { ...this._recordProperties };
151+
152+
if (updateExisting) {
153+
// Look up the DWN record ID of the object in the store with the given `id`.
154+
const matchingRecordId = await this.lookupRecordId({ id, tenantDid, agent });
155+
if (!matchingRecordId) {
156+
throw new Error(`${this.name}: Update failed due to missing entry for: ${id}`);
157+
}
158+
159+
// set the recordId in the messageParams to update the existing record
160+
messageParams.recordId = matchingRecordId;
161+
} else if (preventDuplicates) {
151162
// Look up the DWN record ID of the object in the store with the given `id`.
152163
const matchingRecordId = await this.lookupRecordId({ id, tenantDid, agent });
153164
if (matchingRecordId) {
154165
throw new Error(`${this.name}: Import failed due to duplicate entry for: ${id}`);
155166
}
156167
}
157168

169+
158170
// Convert the store object to a byte array, which will be the data payload of the DWN record.
159171
const dataBytes = Convert.object(data).toUint8Array();
160172

@@ -340,12 +352,19 @@ export class InMemoryDataStore<TStoreObject extends Record<string, any> = Jwk> i
340352
return result;
341353
}
342354

343-
public async set({ id, data, tenant, agent, preventDuplicates }: DataStoreSetParams<TStoreObject>): Promise<void> {
355+
public async set({ id, data, tenant, agent, preventDuplicates, updateExisting }: DataStoreSetParams<TStoreObject>): Promise<void> {
344356
// Determine the tenant identifier (DID) for the set operation.
345357
const tenantDid = await getDataStoreTenant({ agent, tenant, didUri: id });
346358

347359
// If enabled, check if a record with the given `id` is already present in the store.
348-
if (preventDuplicates) {
360+
if (updateExisting) {
361+
// Look up the DWN record ID of the object in the store with the given `id`.
362+
if (!this.store.has(`${tenantDid}${TENANT_SEPARATOR}${id}`)) {
363+
throw new Error(`${this.name}: Update failed due to missing entry for: ${id}`);
364+
}
365+
366+
// set the recordId in the messageParams to update the existing record
367+
} else if (preventDuplicates) {
349368
const duplicateFound = this.store.has(`${tenantDid}${TENANT_SEPARATOR}${id}`);
350369
if (duplicateFound) {
351370
throw new Error(`${this.name}: Import failed due to duplicate entry for: ${id}`);

packages/agent/tests/agent-did-resolver-cach.spec.ts

+45-15
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import { TestAgent } from './utils/test-agent.js';
44

55
import sinon from 'sinon';
66
import { expect } from 'chai';
7-
import { DidJwk } from '@web5/dids';
8-
import { BearerIdentity } from '../src/bearer-identity.js';
7+
import { BearerDid, DidJwk } from '@web5/dids';
8+
import { logger } from '@web5/common';
99

1010
describe('AgentDidResolverCache', () => {
1111
let resolverCache: AgentDidResolverCache;
@@ -61,11 +61,10 @@ describe('AgentDidResolverCache', () => {
6161
});
6262

6363
it('should not call resolve if the DID is not the agent DID or exists as an identity in the agent', async () => {
64-
const did = await DidJwk.create({});
64+
const did = await DidJwk.create();
6565
const getStub = sinon.stub(resolverCache['cache'], 'get').resolves(JSON.stringify({ ttlMillis: Date.now() - 1000, value: { didDocument: { id: did.uri } } }));
66-
const resolveSpy = sinon.spy(testHarness.agent.did, 'resolve');
66+
const resolveSpy = sinon.spy(testHarness.agent.did, 'resolve').withArgs(did.uri);
6767
const nextTickSpy = sinon.stub(resolverCache['cache'], 'nextTick').resolves();
68-
sinon.stub(testHarness.agent.identity, 'get').resolves(undefined);
6968

7069
await resolverCache.get(did.uri),
7170

@@ -77,21 +76,52 @@ describe('AgentDidResolverCache', () => {
7776
expect(nextTickSpy.callCount).to.equal(1);
7877
});
7978

80-
it('should resolve if the DID is managed by the agent', async () => {
81-
const did = await DidJwk.create({});
79+
it('should resolve and update if the DID is managed by the agent', async () => {
80+
const did = await DidJwk.create();
81+
8282
const getStub = sinon.stub(resolverCache['cache'], 'get').resolves(JSON.stringify({ ttlMillis: Date.now() - 1000, value: { didDocument: { id: did.uri } } }));
83-
const resolveSpy = sinon.spy(testHarness.agent.did, 'resolve');
84-
const nextTickSpy = sinon.stub(resolverCache['cache'], 'nextTick').resolves();
85-
sinon.stub(testHarness.agent.identity, 'get').resolves(new BearerIdentity({
86-
metadata: { name: 'Some Name', uri: did.uri, tenant: did.uri },
87-
did,
83+
const resolveSpy = sinon.spy(testHarness.agent.did, 'resolve').withArgs(did.uri);
84+
sinon.stub(resolverCache['cache'], 'nextTick').resolves();
85+
const didApiStub = sinon.stub(testHarness.agent.did, 'get');
86+
const updateSpy = sinon.stub(testHarness.agent.did, 'update').resolves();
87+
didApiStub.withArgs({ didUri: did.uri, tenant: testHarness.agent.agentDid.uri }).resolves(new BearerDid({
88+
uri : did.uri,
89+
document : { id: did.uri },
90+
metadata : { },
91+
keyManager : testHarness.agent.keyManager
8892
}));
8993

9094
await resolverCache.get(did.uri),
9195

9296
// get should be called once, and we also resolve the DId as it's returned by the identity.get method
93-
expect(getStub.callCount).to.equal(1);
94-
expect(resolveSpy.callCount).to.equal(1);
97+
expect(getStub.callCount).to.equal(1, 'get');
98+
expect(resolveSpy.callCount).to.equal(1, 'resolve');
99+
expect(updateSpy.callCount).to.equal(1, 'update');
100+
});
101+
102+
it('should log an error if an update is attempted and fails', async () => {
103+
const did = await DidJwk.create();
104+
105+
const getStub = sinon.stub(resolverCache['cache'], 'get').resolves(JSON.stringify({ ttlMillis: Date.now() - 1000, value: { didDocument: { id: did.uri } } }));
106+
const resolveSpy = sinon.spy(testHarness.agent.did, 'resolve').withArgs(did.uri);
107+
sinon.stub(resolverCache['cache'], 'nextTick').resolves();
108+
const didApiStub = sinon.stub(testHarness.agent.did, 'get');
109+
const updateSpy = sinon.stub(testHarness.agent.did, 'update').rejects(new Error('Some Error'));
110+
const consoleErrorSpy = sinon.stub(logger, 'error');
111+
didApiStub.withArgs({ didUri: did.uri, tenant: testHarness.agent.agentDid.uri }).resolves(new BearerDid({
112+
uri : did.uri,
113+
document : { id: did.uri },
114+
metadata : { },
115+
keyManager : testHarness.agent.keyManager
116+
}));
117+
118+
await resolverCache.get(did.uri),
119+
120+
// get should be called once, and we also resolve the DId as it's returned by the identity.get method
121+
expect(getStub.callCount).to.equal(1, 'get');
122+
expect(resolveSpy.callCount).to.equal(1, 'resolve');
123+
expect(updateSpy.callCount).to.equal(1, 'update');
124+
expect(consoleErrorSpy.callCount).to.equal(1, 'console.error');
95125
});
96126

97127
it('does not cache notFound records', async () => {
@@ -107,7 +137,7 @@ describe('AgentDidResolverCache', () => {
107137

108138
it('throws if the error is anything other than a notFound error', async () => {
109139
const did = testHarness.agent.agentDid.uri;
110-
const getStub = sinon.stub(resolverCache['cache'], 'get').rejects(new Error('Some Error'));
140+
sinon.stub(resolverCache['cache'], 'get').rejects(new Error('Some Error'));
111141

112142
try {
113143
await resolverCache.get(did);

0 commit comments

Comments
 (0)