Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Agent resolver refreshes did store and cache #914

Merged
merged 11 commits into from
Oct 11, 2024
9 changes: 9 additions & 0 deletions .changeset/brave-cameras-reply.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@web5/agent": minor
"@web5/dids": minor
"@web5/identity-agent": minor
"@web5/proxy-agent": minor
"@web5/user-agent": minor
---

Ability to Update a DID
29 changes: 26 additions & 3 deletions packages/agent/src/agent-did-resolver-cache.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { DidResolutionResult, DidResolverCache, DidResolverCacheLevel, DidResolverCacheLevelParams } from '@web5/dids';
import { Web5PlatformAgent } from './types/agent.js';
import { logger } from '@web5/common';


/**
Expand Down Expand Up @@ -47,11 +48,33 @@ 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 })) {

// 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
// upon a successful resolution, we will update both the storage and the cache with the newly resolved Document.
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 the resolution was successful, update the stored DID with the new Document
if (!result.didResolutionMetadata.error && result.didDocument) {

const portableDid = {
...storedDid,
document : result.didDocument,
metadata : result.didDocumentMetadata,
};

try {
// 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
// We don't publish the DID in this case, as it was received by the resolver.
await this.agent.did.update({ portableDid, tenant: this.agent.agentDid.uri, publish: false });
} catch(error: any) {
// if the error is not due to no changes detected, log the error
if (error.message && !error.message.includes('No changes detected, update aborted')) {
logger.error(`Error updating DID: ${error.message}`);
}
}
}
} finally {
this._resolving.delete(did);
Expand Down
55 changes: 54 additions & 1 deletion packages/agent/src/did-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,15 @@ 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';
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',
Expand Down Expand Up @@ -256,6 +257,58 @@ export class AgentDidApi<TKeyManager extends AgentKeyManager = AgentKeyManager>
return verificationMethod;
}

public async update({ tenant, portableDid, publish = true }: {
tenant?: string;
portableDid: PortableDid;
publish?: boolean;
}): Promise<BearerDid> {

// Check if the DID exists in the store.
const existingDid = await this.get({ didUri: portableDid.uri, tenant: tenant ?? portableDid.uri });
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. If no keys are present, this will fail.
// NOTE: We currently do not delete the previous keys from the document.
// TODO: Add support for deleting the keys no longer present in the document.
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 });

await this._store.set({
id : uri,
data : portableDidWithoutKeys,
agent : this.agent,
tenant : tenant ?? uri,
updateExisting : true,
useCache : true
});

if (publish) {
const parsedDid = Did.parse(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;
Expand Down
31 changes: 25 additions & 6 deletions packages/agent/src/store-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -26,6 +26,7 @@ export type DataStoreSetParams<TStoreObject> = DataStoreTenantParams & {
id: string;
data: TStoreObject;
preventDuplicates?: boolean;
updateExisting?: boolean;
useCache?: boolean;
}

Expand Down Expand Up @@ -137,7 +138,7 @@ export class DwnDataStore<TStoreObject extends Record<string, any> = 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<TStoreObject>
): Promise<void> {
// Determine the tenant identifier (DID) for the set operation.
Expand All @@ -146,15 +147,26 @@ export class DwnDataStore<TStoreObject extends Record<string, any> = 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) {
throw new Error(`${this.name}: Import failed due to duplicate entry for: ${id}`);
}
}


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

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

public async set({ id, data, tenant, agent, preventDuplicates }: DataStoreSetParams<TStoreObject>): Promise<void> {
public async set({ id, data, tenant, agent, preventDuplicates, updateExisting }: DataStoreSetParams<TStoreObject>): Promise<void> {
// 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}`);
Expand Down
60 changes: 45 additions & 15 deletions packages/agent/tests/agent-did-resolver-cach.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { TestAgent } from './utils/test-agent.js';

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

describe('AgentDidResolverCache', () => {
let resolverCache: AgentDidResolverCache;
Expand Down Expand Up @@ -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),

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

it('should resolve if the DID is managed by the agent', async () => {
const did = await DidJwk.create({});
it('should resolve and update if the DID is managed by the agent', async () => {
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 nextTickSpy = sinon.stub(resolverCache['cache'], 'nextTick').resolves();
sinon.stub(testHarness.agent.identity, 'get').resolves(new BearerIdentity({
metadata: { name: 'Some Name', uri: did.uri, tenant: did.uri },
did,
const resolveSpy = sinon.spy(testHarness.agent.did, 'resolve').withArgs(did.uri);
sinon.stub(resolverCache['cache'], 'nextTick').resolves();
const didApiStub = sinon.stub(testHarness.agent.did, 'get');
const updateSpy = sinon.stub(testHarness.agent.did, 'update').resolves();
didApiStub.withArgs({ didUri: did.uri, tenant: testHarness.agent.agentDid.uri }).resolves(new BearerDid({
uri : did.uri,
document : { id: did.uri },
metadata : { },
keyManager : testHarness.agent.keyManager
}));

await resolverCache.get(did.uri),

// get should be called once, and we also resolve the DId as it's returned by the identity.get method
expect(getStub.callCount).to.equal(1);
expect(resolveSpy.callCount).to.equal(1);
expect(getStub.callCount).to.equal(1, 'get');
expect(resolveSpy.callCount).to.equal(1, 'resolve');
expect(updateSpy.callCount).to.equal(1, 'update');
});

it('should log an error if an update is attempted and fails', async () => {
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').withArgs(did.uri);
sinon.stub(resolverCache['cache'], 'nextTick').resolves();
const didApiStub = sinon.stub(testHarness.agent.did, 'get');
const updateSpy = sinon.stub(testHarness.agent.did, 'update').rejects(new Error('Some Error'));
const consoleErrorSpy = sinon.stub(logger, 'error');
didApiStub.withArgs({ didUri: did.uri, tenant: testHarness.agent.agentDid.uri }).resolves(new BearerDid({
uri : did.uri,
document : { id: did.uri },
metadata : { },
keyManager : testHarness.agent.keyManager
}));

await resolverCache.get(did.uri),

// get should be called once, and we also resolve the DId as it's returned by the identity.get method
expect(getStub.callCount).to.equal(1, 'get');
expect(resolveSpy.callCount).to.equal(1, 'resolve');
expect(updateSpy.callCount).to.equal(1, 'update');
expect(consoleErrorSpy.callCount).to.equal(1, 'console.error');
});

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

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

try {
await resolverCache.get(did);
Expand Down
Loading
Loading