From 101f463eae4fa54383a8ffd28292755076ce50f4 Mon Sep 17 00:00:00 2001 From: Henry Tsai Date: Fri, 4 Oct 2024 14:01:38 -0700 Subject: [PATCH 1/7] Added parameter for app display name for dynamic rendering in the wallet during web5 connect flow (#945) --- .changeset/lemon-bees-yawn.md | 5 +++++ .changeset/smooth-weeks-serve.md | 5 +++++ packages/agent/src/connect.ts | 10 ++++++++-- packages/agent/src/oidc.ts | 8 ++++---- packages/agent/tests/connect.spec.ts | 8 ++++++++ packages/api/src/web5.ts | 8 +++++++- packages/api/tests/web5.spec.ts | 6 ++++++ packages/common/package.json | 2 +- 8 files changed, 44 insertions(+), 8 deletions(-) create mode 100644 .changeset/lemon-bees-yawn.md create mode 100644 .changeset/smooth-weeks-serve.md diff --git a/.changeset/lemon-bees-yawn.md b/.changeset/lemon-bees-yawn.md new file mode 100644 index 000000000..cf7a444db --- /dev/null +++ b/.changeset/lemon-bees-yawn.md @@ -0,0 +1,5 @@ +--- +"@web5/agent": patch +--- + +Added parameter for app display name for dynamic rendering in the wallet during web5 connect flow diff --git a/.changeset/smooth-weeks-serve.md b/.changeset/smooth-weeks-serve.md new file mode 100644 index 000000000..68eb4483c --- /dev/null +++ b/.changeset/smooth-weeks-serve.md @@ -0,0 +1,5 @@ +--- +"@web5/api": patch +--- + +Added parameter for app display name for dynamic rendering in the wallet during web5 connect flow diff --git a/packages/agent/src/connect.ts b/packages/agent/src/connect.ts index 68aff74e9..9208d3f39 100644 --- a/packages/agent/src/connect.ts +++ b/packages/agent/src/connect.ts @@ -17,6 +17,7 @@ import { DwnInterfaceName, DwnMethodName } from '@tbd54566975/dwn-sdk-js'; * a did from a provider. */ async function initClient({ + displayName, connectServerUrl, walletUri, permissionRequests, @@ -44,10 +45,12 @@ async function initClient({ const request = await Oidc.createAuthRequest({ client_id : clientDid.uri, scope : 'openid did:jwk', + redirect_uri : callbackEndpoint, + // custom properties: // code_challenge : codeChallengeBase64Url, // code_challenge_method : 'S256', permissionRequests : permissionRequests, - redirect_uri : callbackEndpoint, + displayName, }); // Sign the Request Object using the Client DID's signing key. @@ -133,7 +136,10 @@ async function initClient({ * a did from a provider. */ export type WalletConnectOptions = { - /** The URL of the intermediary server which relays messages between the client and provider */ + /** The user friendly name of the client/app to be displayed when prompting end-user with permission requests. */ + displayName: string; + + /** The URL of the intermediary server which relays messages between the client and provider. */ connectServerUrl: string; /** diff --git a/packages/agent/src/oidc.ts b/packages/agent/src/oidc.ts index e56e9eb1f..dc40917c4 100644 --- a/packages/agent/src/oidc.ts +++ b/packages/agent/src/oidc.ts @@ -128,6 +128,9 @@ export type SIOPv2AuthRequest = { * The contents of this are inserted into a JWT inside of the {@link PushedAuthRequest}. */ export type Web5ConnectAuthRequest = { + /** The user friendly name of the client/app to be displayed when prompting end-user with permission requests. */ + displayName: string; + /** PermissionGrants that are to be sent to the provider */ permissionRequests: ConnectPermissionRequest[]; } & SIOPv2AuthRequest; @@ -242,7 +245,7 @@ async function generateCodeChallenge() { async function createAuthRequest( options: RequireOnly< Web5ConnectAuthRequest, - 'client_id' | 'scope' | 'redirect_uri' | 'permissionRequests' + 'client_id' | 'scope' | 'redirect_uri' | 'permissionRequests' | 'displayName' > ) { // Generate a random state value to associate the authorization request with the response. @@ -667,7 +670,6 @@ async function createPermissionGrants( }); const messages = await Promise.all(messagePromises); - return messages; } @@ -693,7 +695,6 @@ async function prepareProtocol( `Could not fetch protocol: ${queryMessage.reply.status.detail}` ); } else if (queryMessage.reply.entries === undefined || queryMessage.reply.entries.length === 0) { - // send the protocol definition to the remote DWN first, if it passes we can process it locally const { reply: sendReply, message: configureMessage } = await agent.sendDwnRequest({ author : selectedDid, @@ -716,7 +717,6 @@ async function prepareProtocol( }); } else { - // the protocol already exists, let's make sure it exists on the remote DWN as the requesting app will need it const configureMessage = queryMessage.reply.entries![0]; const { reply: sendReply } = await agent.sendDwnRequest({ diff --git a/packages/agent/tests/connect.spec.ts b/packages/agent/tests/connect.spec.ts index f6a1b87cb..9f34a8e10 100644 --- a/packages/agent/tests/connect.spec.ts +++ b/packages/agent/tests/connect.spec.ts @@ -224,6 +224,7 @@ describe('web5 connect', function () { }); const options = { + displayName : 'Sample App', client_id : clientEphemeralPortableDid.uri, scope : 'openid did:jwk', // code_challenge : Convert.uint8Array(codeChallenge).toBase64Url(), @@ -457,6 +458,7 @@ describe('web5 connect', function () { fetchStub.callThrough(); const results = await WalletConnect.initClient({ + displayName : 'Sample App', walletUri : 'http://localhost:3000/', connectServerUrl : 'http://localhost:3000/connect', permissionRequests : [ @@ -505,6 +507,7 @@ describe('web5 connect', function () { }); const options = { + displayName : 'Sample App', client_id : clientEphemeralPortableDid.uri, scope : 'openid did:jwk', // code_challenge : Convert.uint8Array(codeChallenge).toBase64Url(), @@ -560,6 +563,7 @@ describe('web5 connect', function () { }); const options = { + displayName : 'Sample App', client_id : clientEphemeralPortableDid.uri, scope : 'openid did:jwk', // code_challenge : Convert.uint8Array(codeChallenge).toBase64Url(), @@ -632,6 +636,7 @@ describe('web5 connect', function () { }); const options = { + displayName : 'Sample App', client_id : clientEphemeralPortableDid.uri, scope : 'openid did:jwk', // code_challenge : Convert.uint8Array(codeChallenge).toBase64Url(), @@ -679,6 +684,7 @@ describe('web5 connect', function () { }); const options = { + displayName : 'Sample App', client_id : clientEphemeralPortableDid.uri, scope : 'openid did:jwk', // code_challenge : Convert.uint8Array(codeChallenge).toBase64Url(), @@ -730,6 +736,7 @@ describe('web5 connect', function () { }); const options = { + displayName : 'Sample App', client_id : clientEphemeralPortableDid.uri, scope : 'openid did:jwk', // code_challenge : Convert.uint8Array(codeChallenge).toBase64Url(), @@ -781,6 +788,7 @@ describe('web5 connect', function () { mismatchedScopes[0].protocol = 'http://profile-protocol.xyz/other'; const options = { + displayName : 'Sample App', client_id : clientEphemeralPortableDid.uri, scope : 'openid did:jwk', // code_challenge : Convert.uint8Array(codeChallenge).toBase64Url(), diff --git a/packages/api/src/web5.ts b/packages/api/src/web5.ts index 6636026f5..bc5ee95bd 100644 --- a/packages/api/src/web5.ts +++ b/packages/api/src/web5.ts @@ -44,6 +44,7 @@ export type ConnectPermissionRequest = { * The protocol definition for the protocol being requested. */ protocolDefinition: DwnProtocolDefinition; + /** * The permissions being requested for the protocol. If none are provided, the default is to request all permissions. */ @@ -51,9 +52,14 @@ export type ConnectPermissionRequest = { } /** - * Options for connecting to a Web5 agent. This includes the ability to connect to an external wallet + * Options for connecting to a Web5 agent. This includes the ability to connect to an external wallet. + * + * NOTE: the returned `ConnectPermissionRequest` type is different to the `ConnectPermissionRequest` type in the `@web5/agent` package. */ export type ConnectOptions = Omit & { + /** The user friendly name of the client/app to be displayed when prompting end-user with permission requests. */ + displayName: string; + /** * The permissions that are being requested for the connected DID. * This is used to create the {@link ConnectPermissionRequest} for the wallet connect flow. diff --git a/packages/api/tests/web5.spec.ts b/packages/api/tests/web5.spec.ts index be0016963..4576989c4 100644 --- a/packages/api/tests/web5.spec.ts +++ b/packages/api/tests/web5.spec.ts @@ -455,6 +455,7 @@ describe('web5 api', () => { // connect to the app, the options don't matter because we're stubbing the initClient method const { web5, did, delegateDid } = await Web5.connect({ walletConnectOptions: { + displayName : 'Sample App', connectServerUrl : 'https://connect.example.com', walletUri : 'https://wallet.example.com', validatePin : async () => { return '1234'; }, @@ -675,6 +676,7 @@ describe('web5 api', () => { // connect to the app, the options don't matter because we're stubbing the initClient method await Web5.connect({ walletConnectOptions: { + displayName : 'Sample App', connectServerUrl : 'https://connect.example.com', walletUri : 'https://wallet.example.com', validatePin : async () => { return '1234'; }, @@ -735,6 +737,7 @@ describe('web5 api', () => { await Web5.connect({ sync : 'off', walletConnectOptions : { + displayName : 'Sample App', connectServerUrl : 'https://connect.example.com', walletUri : 'https://wallet.example.com', validatePin : async () => { return '1234'; }, @@ -779,6 +782,7 @@ describe('web5 api', () => { await Web5.connect({ sync : '1m', walletConnectOptions : { + displayName : 'Sample App', connectServerUrl : 'https://connect.example.com', walletUri : 'https://wallet.example.com', validatePin : async () => { return '1234'; }, @@ -822,6 +826,7 @@ describe('web5 api', () => { await Web5.connect({ walletConnectOptions: { + displayName : 'Sample App', connectServerUrl : 'https://connect.example.com', walletUri : 'https://wallet.example.com', validatePin : async () => { return '1234'; }, @@ -893,6 +898,7 @@ describe('web5 api', () => { await Web5.connect({ walletConnectOptions: { + displayName : 'Sample App', connectServerUrl : 'https://connect.example.com', walletUri : 'https://wallet.example.com', validatePin : async () => { return '1234'; }, diff --git a/packages/common/package.json b/packages/common/package.json index b7449eb47..8250fcb68 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -99,4 +99,4 @@ "rimraf": "5.0.7", "typescript": "5.5.3" } -} +} \ No newline at end of file From a2cd9d53f73c0f35e505789fc26c2e012a3ac4d9 Mon Sep 17 00:00:00 2001 From: Henry Tsai Date: Fri, 4 Oct 2024 14:46:45 -0700 Subject: [PATCH 2/7] Added logger and some usage (#947) --- packages/agent/src/connect.ts | 3 +- packages/agent/src/oidc.ts | 23 ++++++++++-- packages/common/src/index.ts | 1 + packages/common/src/logger.ts | 70 +++++++++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 packages/common/src/logger.ts diff --git a/packages/agent/src/connect.ts b/packages/agent/src/connect.ts index 9208d3f39..fb19b9983 100644 --- a/packages/agent/src/connect.ts +++ b/packages/agent/src/connect.ts @@ -7,7 +7,7 @@ import { } from './oidc.js'; import { pollWithTtl } from './utils.js'; -import { Convert } from '@web5/common'; +import { Convert, logger } from '@web5/common'; import { CryptoUtils } from '@web5/crypto'; import { DidJwk } from '@web5/dids'; import { DwnInterfaceName, DwnMethodName } from '@tbd54566975/dwn-sdk-js'; @@ -94,6 +94,7 @@ async function initClient({ // a deeplink to a web5 compatible wallet. if the wallet scans this link it should receive // a route to its web5 connect provider flow and the params of where to fetch the auth request. + logger.log(`Wallet URI: ${walletUri}`); const generatedWalletUri = new URL(walletUri); generatedWalletUri.searchParams.set('request_uri', parData.request_uri); generatedWalletUri.searchParams.set( diff --git a/packages/agent/src/oidc.ts b/packages/agent/src/oidc.ts index dc40917c4..076a0b72a 100644 --- a/packages/agent/src/oidc.ts +++ b/packages/agent/src/oidc.ts @@ -1,4 +1,4 @@ -import { Convert, RequireOnly } from '@web5/common'; +import { Convert, logger, RequireOnly } from '@web5/common'; import { Ed25519, EdDsaAlgorithm, @@ -631,6 +631,7 @@ async function createPermissionGrants( const permissionsApi = new AgentPermissionsApi({ agent }); // TODO: cleanup all grants if one fails by deleting them from the DWN: https://github.com/TBD54566975/web5-js/issues/849 + logger.log(`Creating permission grants for ${scopes.length} scopes given...`); const permissionGrants = await Promise.all( scopes.map((scope) => { // check if the scope is a records permission scope, or a protocol configure scope, if so it should use a delegated permission. @@ -646,6 +647,7 @@ async function createPermissionGrants( }) ); + logger.log(`Sending ${permissionGrants.length} permission grants to remote DWN...`); const messagePromises = permissionGrants.map(async (grant) => { // Quirk: we have to pull out encodedData out of the message the schema validator doesn't want it there const { encodedData, ...rawMessage } = grant.message; @@ -661,6 +663,8 @@ async function createPermissionGrants( // check if the message was sent successfully, if the remote returns 409 the message may have come through already via sync if (reply.status.code !== 202 && reply.status.code !== 409) { + logger.error(`Error sending RecordsWrite: ${reply.status.detail}`); + logger.error(`RecordsWrite message: ${rawMessage}`); throw new Error( `Could not send the message. Error details: ${reply.status.detail}` ); @@ -669,8 +673,13 @@ async function createPermissionGrants( return grant.message; }); - const messages = await Promise.all(messagePromises); - return messages; + try { + const messages = await Promise.all(messagePromises); + return messages; + } catch (error) { + logger.error(`Error during batch-send of permission grants: ${error}`); + throw error; + } } /** @@ -695,6 +704,8 @@ async function prepareProtocol( `Could not fetch protocol: ${queryMessage.reply.status.detail}` ); } else if (queryMessage.reply.entries === undefined || queryMessage.reply.entries.length === 0) { + logger.log(`Protocol does not exist, creating: ${protocolDefinition.protocol}`); + // send the protocol definition to the remote DWN first, if it passes we can process it locally const { reply: sendReply, message: configureMessage } = await agent.sendDwnRequest({ author : selectedDid, @@ -717,6 +728,8 @@ async function prepareProtocol( }); } else { + logger.log(`Protocol already exists: ${protocolDefinition.protocol}`); + // the protocol already exists, let's make sure it exists on the remote DWN as the requesting app will need it const configureMessage = queryMessage.reply.entries![0]; const { reply: sendReply } = await agent.sendDwnRequest({ @@ -776,6 +789,7 @@ async function submitAuthResponse( const delegateGrants = (await Promise.all(delegateGrantPromises)).flat(); + logger.log('Generating auth response object...'); const responseObject = await Oidc.createResponseObject({ //* the IDP's did that was selected to be connected iss : selectedDid, @@ -790,6 +804,7 @@ async function submitAuthResponse( }); // Sign the Response Object using the ephemeral DID's signing key. + logger.log('Signing auth response object...'); const responseObjectJwt = await Oidc.signJwt({ did : delegateBearerDid, data : responseObject, @@ -801,6 +816,7 @@ async function submitAuthResponse( clientDid?.didDocument! ); + logger.log('Encrypting auth response object...'); const encryptedResponse = Oidc.encryptAuthResponse({ jwt : responseObjectJwt!, encryptionKey : sharedKey, @@ -813,6 +829,7 @@ async function submitAuthResponse( state : authRequest.state, }).toString(); + logger.log(`Sending auth response object to Web5 Connect server: ${authRequest.redirect_uri}`); await fetch(authRequest.redirect_uri, { body : formEncodedRequest, method : 'POST', diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 1a6095561..0f4b01691 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -2,6 +2,7 @@ export type * from './types.js'; export * from './cache.js'; export * from './convert.js'; +export * from './logger.js'; export * from './multicodec.js'; export * from './object.js'; export * from './stores.js'; diff --git a/packages/common/src/logger.ts b/packages/common/src/logger.ts new file mode 100644 index 000000000..86fbb7f6c --- /dev/null +++ b/packages/common/src/logger.ts @@ -0,0 +1,70 @@ +/** + * Web5 logger level. + */ +export enum Web5LogLevel { + Debug = 'debug', + Silent = 'silent', +} + +/** + * Web5 logger interface. + */ +export interface Web5LoggerInterface { + + /** + * Sets the log verbose level. + */ + setLogLevel(logLevel: Web5LogLevel): void; + + /** + * Same as `info()`. + * Logs an informational message. + */ + log (message: string): void; + + /** + * Logs an informational message. + */ + info(message: string): void; + + /** + * Logs an error message. + */ + error(message: string): void; +} + +/** + * A Web5 logger implementation. + */ +class Web5Logger implements Web5LoggerInterface { + private logLevel: Web5LogLevel = Web5LogLevel.Silent; // Default to silent/no-op log level + + setLogLevel(logLevel: Web5LogLevel): void { + this.logLevel = logLevel; + } + + public log(message: string): void { + this.info(message); + } + + public info(message: string): void { + if (this.logLevel === Web5LogLevel.Silent) { return; } + + console.info(message); + } + + public error(message: string): void { + if (this.logLevel === Web5LogLevel.Silent) { return; } + + console.error(message); + } +} + +// Export a singleton logger instance +export const logger = new Web5Logger(); + +// Attach logger to the global window object in browser environment for easy access to the logger instance. +// e.g. can call `web5logger.setLogLevel('debug');` directly in browser console. +if (typeof window !== 'undefined') { + (window as any).web5logger = logger; // Makes `web5Logger` accessible globally in browser +} \ No newline at end of file From bfa0417a2e9fc1300c5e604bea19b75ab1c73645 Mon Sep 17 00:00:00 2001 From: Henry Tsai Date: Fri, 4 Oct 2024 16:43:33 -0700 Subject: [PATCH 3/7] Forgot to create a changeset for common (#948) --- .changeset/yellow-schools-fail.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/yellow-schools-fail.md diff --git a/.changeset/yellow-schools-fail.md b/.changeset/yellow-schools-fail.md new file mode 100644 index 000000000..5ba605de7 --- /dev/null +++ b/.changeset/yellow-schools-fail.md @@ -0,0 +1,5 @@ +--- +"@web5/common": minor +--- + +Added a logger. From aaf4b4a23cada03a50f4dab6c9ce1c6200bf07ef Mon Sep 17 00:00:00 2001 From: Henry Tsai Date: Thu, 10 Oct 2024 09:47:53 -0700 Subject: [PATCH 4/7] Updated dwn-sdk-js dependency to v0.5.1 (#950) * Updated dwn-sdk-js dependency to v0.5.1 * Updated dwn-server docker image * Updated dwn-server dependency --- package.json | 4 +- packages/agent/package.json | 4 +- packages/agent/src/store-data.ts | 4 +- packages/agent/tests/dwn-api.spec.ts | 20 ++++---- .../clients/http-dwn-rpc-client.spec.ts | 4 +- .../clients/ws-dwn-rpc-client.spec.ts | 4 +- .../agent/tests/sync-engine-level.spec.ts | 10 ++-- packages/api/package.json | 4 +- packages/api/src/dwn-api.ts | 8 +-- packages/api/src/record.ts | 4 +- packages/dev-env/docker-compose.yaml | 2 +- pnpm-lock.yaml | 49 ++++++++++++------- 12 files changed, 66 insertions(+), 51 deletions(-) diff --git a/package.json b/package.json index 903e77a75..90f40f01e 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "@changesets/cli": "^2.27.5", "@npmcli/package-json": "5.0.0", "@typescript-eslint/eslint-plugin": "7.9.0", - "@web5/dwn-server": "0.4.10", + "@web5/dwn-server": "0.6.0", "audit-ci": "^7.0.1", "eslint-plugin-mocha": "10.4.3", "globals": "^13.24.0", @@ -60,4 +60,4 @@ "rollup@>=4.0.0 <4.22.4": ">=4.22.4" } } -} +} \ No newline at end of file diff --git a/packages/agent/package.json b/packages/agent/package.json index c2b248f30..e6ee360dc 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -71,7 +71,7 @@ "dependencies": { "@noble/ciphers": "0.5.3", "@scure/bip39": "1.2.2", - "@tbd54566975/dwn-sdk-js": "0.4.7", + "@tbd54566975/dwn-sdk-js": "0.5.1", "@web5/common": "workspace:*", "@web5/crypto": "workspace:*", "@web5/dids": "workspace:*", @@ -110,4 +110,4 @@ "sinon": "18.0.0", "typescript": "5.1.6" } -} +} \ No newline at end of file diff --git a/packages/agent/src/store-data.ts b/packages/agent/src/store-data.ts index 7d5699022..cc2b0535b 100644 --- a/packages/agent/src/store-data.ts +++ b/packages/agent/src/store-data.ts @@ -243,12 +243,12 @@ export class DwnDataStore = Jwk> implem messageParams : { filter: { recordId } } }); - if (!readReply.record?.data) { + if (!readReply.entry?.data) { throw new Error(`${this.name}: Failed to read data from DWN for: ${recordId}`); } // If the record was found, convert back to store object format. - const storeObject = await NodeStream.consumeToJson({ readable: readReply.record.data }) as TStoreObject; + const storeObject = await NodeStream.consumeToJson({ readable: readReply.entry.data }) as TStoreObject; // If caching is enabled, add the store object to the cache. if (useCache) { diff --git a/packages/agent/tests/dwn-api.spec.ts b/packages/agent/tests/dwn-api.spec.ts index 85c5abc2b..c089f13bf 100644 --- a/packages/agent/tests/dwn-api.spec.ts +++ b/packages/agent/tests/dwn-api.spec.ts @@ -486,12 +486,12 @@ describe('AgentDwnApi', () => { const readReply = readResponse.reply; expect(readReply).to.have.property('status'); expect(readReply.status.code).to.equal(200); - expect(readReply).to.have.property('record'); - expect(readReply.record).to.have.property('data'); - expect(readReply.record).to.have.property('descriptor'); - expect(readReply.record).to.have.property('recordId', writeMessage.recordId); + expect(readReply).to.have.property('entry'); + expect(readReply.entry).to.have.property('data'); + expect(readReply.entry!.recordsWrite).to.have.property('descriptor'); + expect(readReply.entry!.recordsWrite).to.have.property('recordId', writeMessage.recordId); - const readDataBytes = await NodeStream.consumeToBytes({ readable: readReply.record!.data }); + const readDataBytes = await NodeStream.consumeToBytes({ readable: readReply.entry!.data! }); expect(readDataBytes).to.deep.equal(dataBytes); }); @@ -1529,12 +1529,12 @@ describe('AgentDwnApi', () => { const readReply = readResponse.reply; expect(readReply).to.have.property('status'); expect(readReply.status.code).to.equal(200); - expect(readReply).to.have.property('record'); - expect(readReply.record).to.have.property('data'); - expect(readReply.record).to.have.property('descriptor'); - expect(readReply.record).to.have.property('recordId', writeMessage.recordId); + expect(readReply).to.have.property('entry'); + expect(readReply.entry).to.have.property('data'); + expect(readReply.entry?.recordsWrite).to.have.property('descriptor'); + expect(readReply.entry?.recordsWrite).to.have.property('recordId', writeMessage.recordId); - const dataStream: ReadableStream | Readable = readReply.record!.data; + const dataStream: ReadableStream | Readable = readReply.entry!.data!; // If the data stream is a web ReadableStream, convert it to a Node.js Readable. const nodeReadable = Stream.isReadableStream(dataStream) ? NodeStream.fromWebReadable({ readableStream: dataStream }) : diff --git a/packages/agent/tests/prototyping/clients/http-dwn-rpc-client.spec.ts b/packages/agent/tests/prototyping/clients/http-dwn-rpc-client.spec.ts index b860d041f..1cf3e5b11 100644 --- a/packages/agent/tests/prototyping/clients/http-dwn-rpc-client.spec.ts +++ b/packages/agent/tests/prototyping/clients/http-dwn-rpc-client.spec.ts @@ -74,8 +74,8 @@ describe('HttpDwnRpcClient', () => { // should return success, and the record we inserted expect(readResponse.status.code).to.equal(200); - expect(readResponse.record).to.exist; - expect(readResponse.record?.recordId).to.equal(writeMessage.recordId); + expect(readResponse.entry).to.exist; + expect(readResponse.entry?.recordsWrite?.recordId).to.equal(writeMessage.recordId); }); it('throws error if invalid response exists in the header', async () => { diff --git a/packages/agent/tests/prototyping/clients/ws-dwn-rpc-client.spec.ts b/packages/agent/tests/prototyping/clients/ws-dwn-rpc-client.spec.ts index 4d3d84d5f..e1d68f013 100644 --- a/packages/agent/tests/prototyping/clients/ws-dwn-rpc-client.spec.ts +++ b/packages/agent/tests/prototyping/clients/ws-dwn-rpc-client.spec.ts @@ -144,8 +144,8 @@ describe('WebSocketDwnRpcClient', () => { // should return success, and the record we inserted expect(readResponse.status.code).to.equal(200); - expect(readResponse.record).to.exist; - expect(readResponse.record?.recordId).to.equal(writeMessage.recordId); + expect(readResponse.entry).to.exist; + expect(readResponse.entry?.recordsWrite?.recordId).to.equal(writeMessage.recordId); }); it('subscribes to updates to a record', async () => { diff --git a/packages/agent/tests/sync-engine-level.spec.ts b/packages/agent/tests/sync-engine-level.spec.ts index 3d5653e03..4ce1a509d 100644 --- a/packages/agent/tests/sync-engine-level.spec.ts +++ b/packages/agent/tests/sync-engine-level.spec.ts @@ -1109,9 +1109,9 @@ describe('SyncEngineLevel', () => { messageParams : { filter: { recordId: writeResponse.message!.recordId } } }); expect(readResponse.reply.status.code).to.equal(200); - expect(readResponse.reply.record).to.exist; - expect(readResponse.reply.record!.data).to.exist; - expect(readResponse.reply.record!.descriptor.dataSize).to.equal(LARGE_DATA_SIZE); + expect(readResponse.reply.entry).to.exist; + expect(readResponse.reply.entry!.data).to.exist; + expect(readResponse.reply.entry!.recordsWrite!.descriptor.dataSize).to.equal(LARGE_DATA_SIZE); }).slow(1200); // Yellow at 600ms, Red at 1200ms. it('synchronizes records for multiple identities from remote DWN to local DWN', async () => { @@ -1776,8 +1776,8 @@ describe('SyncEngineLevel', () => { }); const reply = readRecord.reply; expect(reply.status.code).to.equal(200); - expect(reply.record).to.not.be.undefined; - expect(reply.record!.data).to.not.be.undefined; + expect(reply.entry).to.exist; + expect(reply.entry!.data).to.exist; }).slow(1200); // Yellow at 600ms, Red at 1200ms. it('synchronizes records for multiple identities from local DWN to remote DWN', async () => { diff --git a/packages/api/package.json b/packages/api/package.json index b9717cd48..590f7474b 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -85,7 +85,7 @@ }, "devDependencies": { "@playwright/test": "1.45.3", - "@tbd54566975/dwn-sdk-js": "0.4.7", + "@tbd54566975/dwn-sdk-js": "0.5.1", "@types/chai": "4.3.6", "@types/eslint": "8.56.10", "@types/mocha": "10.0.1", @@ -109,4 +109,4 @@ "source-map-loader": "4.0.2", "typescript": "5.1.6" } -} +} \ No newline at end of file diff --git a/packages/api/src/dwn-api.ts b/packages/api/src/dwn-api.ts index 464b1bb00..e91f21622 100644 --- a/packages/api/src/dwn-api.ts +++ b/packages/api/src/dwn-api.ts @@ -764,7 +764,7 @@ export class DwnApi { agentResponse = await this.agent.processDwnRequest(agentRequest); } - const { reply: { record: responseRecord, status } } = agentResponse; + const { reply: { entry, status } } = agentResponse; let record: Record; if (200 <= status.code && status.code <= 299) { @@ -773,7 +773,7 @@ export class DwnApi { * Extract the `author` DID from the record since records may be signed by the * tenant owner or any other entity. */ - author : getRecordAuthor(responseRecord), + author : getRecordAuthor(entry.recordsWrite), /** * Set the `connectedDid` to currently connected DID so that subsequent calls to * {@link Record} instance methods, such as `record.update()` are executed on the @@ -788,7 +788,9 @@ export class DwnApi { */ remoteOrigin : request.from, delegateDid : this.delegateDid, - ...responseRecord, + data : entry.data, + initialWrite : entry.initialWrite, + ...entry.recordsWrite, }; record = new Record(this.agent, recordOptions, this.permissionsApi); diff --git a/packages/api/src/record.ts b/packages/api/src/record.ts index bb0c51055..47b703c24 100644 --- a/packages/api/src/record.ts +++ b/packages/api/src/record.ts @@ -1062,12 +1062,12 @@ export class Record implements RecordModel { this._agent.processDwnRequest(readRequest); try { - const { reply: { status, record }} = await agentResponsePromise; + const { reply: { status, entry }} = await agentResponsePromise; if (status.code !== 200) { throw new Error(`${status.code}: ${status.detail}`); } - const dataStream: ReadableStream | Readable = record.data; + const dataStream: ReadableStream | Readable = entry.data; // If the data stream is a web ReadableStream, convert it to a Node.js Readable. const nodeReadable = Stream.isReadableStream(dataStream) ? NodeStream.fromWebReadable({ readableStream: dataStream }) : diff --git a/packages/dev-env/docker-compose.yaml b/packages/dev-env/docker-compose.yaml index f4beb63af..25cc3690d 100644 --- a/packages/dev-env/docker-compose.yaml +++ b/packages/dev-env/docker-compose.yaml @@ -3,6 +3,6 @@ version: "3.98" services: dwn-server: container_name: dwn-server - image: ghcr.io/tbd54566975/dwn-server:0.4.10 + image: ghcr.io/tbd54566975/dwn-server:0.6.0 ports: - "3000:3000" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3252bf6e3..c9ace1012 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,8 +41,8 @@ importers: specifier: 7.9.0 version: 7.9.0(@typescript-eslint/parser@7.14.1(eslint@9.7.0)(typescript@5.5.4))(eslint@9.7.0)(typescript@5.5.4) '@web5/dwn-server': - specifier: 0.4.10 - version: 0.4.10 + specifier: 0.6.0 + version: 0.6.0 audit-ci: specifier: ^7.0.1 version: 7.1.0 @@ -65,8 +65,8 @@ importers: specifier: 1.2.2 version: 1.2.2 '@tbd54566975/dwn-sdk-js': - specifier: 0.4.7 - version: 0.4.7 + specifier: 0.5.1 + version: 0.5.1 '@web5/common': specifier: workspace:* version: link:../common @@ -193,8 +193,8 @@ importers: specifier: 1.45.3 version: 1.45.3 '@tbd54566975/dwn-sdk-js': - specifier: 0.4.7 - version: 0.4.7 + specifier: 0.5.1 + version: 0.5.1 '@types/chai': specifier: 4.3.6 version: 4.3.6 @@ -2176,12 +2176,12 @@ packages: '@sphereon/ssi-types@0.26.0': resolution: {integrity: sha512-r4JQIN7rnPunEv0HvCFC1ZCc9qlWcegYvhJbMJqSvyFE6VhmT5NNdH9jNV9QetgMa0yo5r3k+TnHNv3nH58Dmg==} - '@tbd54566975/dwn-sdk-js@0.4.7': - resolution: {integrity: sha512-VYaLT4FKdHfVvUPZbicUpF77erkOSi1xBP/EVQIpnp0khPujp2lYcojbRcw4c4JR23CrRvLPy/iWXmEhdP8LqA==} + '@tbd54566975/dwn-sdk-js@0.5.1': + resolution: {integrity: sha512-4xfDttiXOzs7h3PHODflHxHP7Z1HlNod6BN+HX6elXKlqOlBueORrzB72BnzVBbv35eDRJR5w2upmGsUoq3dGg==} engines: {node: '>= 18'} - '@tbd54566975/dwn-sql-store@0.6.7': - resolution: {integrity: sha512-5v/BudrItBx8UUMEIH42nMBwykpM9ZyBpMERmWwJn06Xe47wv+ojkDhVX000Npuv4q+bsLv0lQhCaIAmKcMlaQ==} + '@tbd54566975/dwn-sql-store@0.6.8': + resolution: {integrity: sha512-2F1ACH9GKUBQm8kEKzyLdWw36Dakhx+Z8HAbPsNqbj9w8qht/AEykTJNhvcAi07G2Un83PfqCYDJURs8tM92tA==} engines: {node: '>=18'} '@tootallnate/quickjs-emscripten@0.23.0': @@ -2559,6 +2559,10 @@ packages: resolution: {integrity: sha512-dxczXqzWt6HCwuNyOVBeakg6GgOpP74tVEVxBeKkb+D3XcSP96mYaDtky5ZnjY4iBYb16SaCgwje+sgevOL51A==} engines: {node: '>=18.0.0'} + '@web5/common@1.0.2': + resolution: {integrity: sha512-SerGdrxZF47yidvhrRa8sGLEOunIlDHppxrtWYCuKMVgtQKgheEmaS4+xchGAc/mZggJX4LlwJbRuniIiSaXrw==} + engines: {node: '>=18.0.0'} + '@web5/crypto@1.0.3': resolution: {integrity: sha512-gZJKo0scX+L53E2K/5cgEiFYxejzHP2RSg64ncF6TitOnCNxUyWjofovgufb+u3ZpGC4iuliD7V0o1C+V73Law==} engines: {node: '>=18.0.0'} @@ -2567,8 +2571,8 @@ packages: resolution: {integrity: sha512-M9EfsEYcOtYuEvUQjow4vpxXbD0Sz5H8EuDXMtwuvP4UdYL0ATl+60F8+8HDmwPFeUy6M2wxuoixrLDwSRFwZA==} engines: {node: '>=18.0.0'} - '@web5/dwn-server@0.4.10': - resolution: {integrity: sha512-gdXIDC4OkCS58+EG85SN82IeWynl3uqkpeoq79A6X9NCGWO9+5XM5pNKCjkPxxNdsGfz0sX+nYLkSqrRX5BcFA==} + '@web5/dwn-server@0.6.0': + resolution: {integrity: sha512-jsY/RnefkDSJi8RVtWycc4InyBPZoohY2gntICNwbAzk1D7tJotArJIp7D8So9Id5eIau2SxJjSKkYoAWuedkQ==} hasBin: true '@webassemblyjs/ast@1.12.1': @@ -7423,7 +7427,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tbd54566975/dwn-sdk-js@0.4.7': + '@tbd54566975/dwn-sdk-js@0.5.1': dependencies: '@ipld/dag-cbor': 9.0.3 '@js-temporal/polyfill': 0.4.4 @@ -7456,10 +7460,10 @@ snapshots: - encoding - supports-color - '@tbd54566975/dwn-sql-store@0.6.7': + '@tbd54566975/dwn-sql-store@0.6.8': dependencies: '@ipld/dag-cbor': 9.0.5 - '@tbd54566975/dwn-sdk-js': 0.4.7 + '@tbd54566975/dwn-sdk-js': 0.5.1 kysely: 0.26.3 multiformats: 12.0.1 readable-stream: 4.4.2 @@ -8359,6 +8363,13 @@ snapshots: multiformats: 13.1.0 readable-stream: 4.4.2 + '@web5/common@1.0.2': + dependencies: + '@isaacs/ttlcache': 1.4.1 + level: 8.0.1 + multiformats: 13.1.0 + readable-stream: 4.5.2 + '@web5/crypto@1.0.3': dependencies: '@noble/ciphers': 0.5.3 @@ -8378,11 +8389,13 @@ snapshots: level: 8.0.1 ms: 2.1.3 - '@web5/dwn-server@0.4.10': + '@web5/dwn-server@0.6.0': dependencies: - '@tbd54566975/dwn-sdk-js': 0.4.7 - '@tbd54566975/dwn-sql-store': 0.6.7 + '@tbd54566975/dwn-sdk-js': 0.5.1 + '@tbd54566975/dwn-sql-store': 0.6.8 + '@web5/common': 1.0.2 '@web5/crypto': 1.0.3 + '@web5/dids': 1.1.3 better-sqlite3: 8.7.0 body-parser: 1.20.3 bytes: 3.1.2 From bd1cb00616029b0d18687b597e90d3b7c4dbeae1 Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Fri, 11 Oct 2024 16:29:58 -0400 Subject: [PATCH 5/7] 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`. --- .changeset/brave-cameras-reply.md | 9 + .../agent/src/agent-did-resolver-cache.ts | 29 ++- packages/agent/src/did-api.ts | 55 ++++- packages/agent/src/store-data.ts | 31 ++- .../tests/agent-did-resolver-cach.spec.ts | 60 +++-- packages/agent/tests/did-api.spec.ts | 223 +++++++++++++++++- .../agent/tests/local-key-manager.spec.ts | 18 +- packages/agent/tests/store-data.spec.ts | 52 ++++ packages/dids/src/bearer-did.ts | 9 +- packages/dids/tests/bearer-did.spec.ts | 18 ++ 10 files changed, 474 insertions(+), 30 deletions(-) create mode 100644 .changeset/brave-cameras-reply.md diff --git a/.changeset/brave-cameras-reply.md b/.changeset/brave-cameras-reply.md new file mode 100644 index 000000000..49b67b0a6 --- /dev/null +++ b/.changeset/brave-cameras-reply.md @@ -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 diff --git a/packages/agent/src/agent-did-resolver-cache.ts b/packages/agent/src/agent-did-resolver-cache.ts index ecf35b76b..5bb635fdb 100644 --- a/packages/agent/src/agent-did-resolver-cache.ts +++ b/packages/agent/src/agent-did-resolver-cache.ts @@ -1,5 +1,6 @@ import { DidResolutionResult, DidResolverCache, DidResolverCacheLevel, DidResolverCacheLevelParams } from '@web5/dids'; import { Web5PlatformAgent } from './types/agent.js'; +import { logger } from '@web5/common'; /** @@ -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); diff --git a/packages/agent/src/did-api.ts b/packages/agent/src/did-api.ts index 91abb5807..10ea60777 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,58 @@ 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: 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; diff --git a/packages/agent/src/store-data.ts b/packages/agent/src/store-data.ts index cc2b0535b..c4fbea41e 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..ecf75de76 100644 --- a/packages/agent/tests/agent-did-resolver-cach.spec.ts +++ b/packages/agent/tests/agent-did-resolver-cach.spec.ts @@ -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; @@ -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), @@ -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 () => { @@ -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); diff --git a/packages/agent/tests/did-api.spec.ts b/packages/agent/tests/did-api.spec.ts index d7f5148d2..53037af6c 100644 --- a/packages/agent/tests/did-api.spec.ts +++ b/packages/agent/tests/did-api.spec.ts @@ -1,6 +1,6 @@ import sinon from 'sinon'; import { expect } from 'chai'; -import { BearerDid, DidJwk } from '@web5/dids'; +import { BearerDid, DidDht, DidJwk, PortableDid } from '@web5/dids'; import type { Web5PlatformAgent } from '../src/types/agent.js'; @@ -300,7 +300,25 @@ describe('AgentDidApi', () => { }); describe('export()', () => { - xit('should be implemented'); + it('exports a DID to a PortableDid object', async () => { + // Generate a new DID. + const did = await DidJwk.create(); + const portableDid = await did.export(); + + // import the DID + await testHarness.agent.did.import({ portableDid, tenant: testHarness.agent.agentDid.uri }); + + // Export the DID to a PortableDid object. + const exportedDid = await testHarness.agent.did.export({ didUri: did.uri, tenant: testHarness.agent.agentDid.uri }); + + // Verify the result. + expect(exportedDid).to.have.property('uri', did.uri); + expect(exportedDid).to.have.property('document'); + expect(exportedDid).to.have.property('metadata'); + + // Verify the exported document. + expect(exportedDid.document).to.deep.equal(portableDid.document); + }); }); describe('import()', () => { @@ -481,7 +499,206 @@ describe('AgentDidApi', () => { }); describe('update()', () => { - xit('should be implemented'); + beforeEach(async () => { + // Generate a new DID. + const mockedPortableDid: PortableDid = { + uri : 'did:dht:ksbkpsjytbm7kh6hnt3xi91t6to98zndtrrxzsqz9y87m5qztyqo', + document : { + '@context' : 'https://www.w3.org/ns/did/v1', + id : 'did:dht:ksbkpsjytbm7kh6hnt3xi91t6to98zndtrrxzsqz9y87m5qztyqo', + verificationMethod : [ + { + id : 'did:dht:ksbkpsjytbm7kh6hnt3xi91t6to98zndtrrxzsqz9y87m5qztyqo#0', + type : 'JsonWebKey', + controller : 'did:dht:ksbkpsjytbm7kh6hnt3xi91t6to98zndtrrxzsqz9y87m5qztyqo', + publicKeyJwk : { + crv : 'Ed25519', + kty : 'OKP', + x : 'VYKm2SCIV9Vz3BRy-v5R9GHz3EOJCPvZ1_gP1e3XiB0', + kid : 'cyvOypa6k-4ffsRWcza37s5XVOh1kO9ICUeo1ZxHVM8', + alg : 'EdDSA', + }, + }, + ], + authentication : ['did:dht:ksbkpsjytbm7kh6hnt3xi91t6to98zndtrrxzsqz9y87m5qztyqo#0'], + assertionMethod : ['did:dht:ksbkpsjytbm7kh6hnt3xi91t6to98zndtrrxzsqz9y87m5qztyqo#0'], + capabilityDelegation : ['did:dht:ksbkpsjytbm7kh6hnt3xi91t6to98zndtrrxzsqz9y87m5qztyqo#0'], + capabilityInvocation : ['did:dht:ksbkpsjytbm7kh6hnt3xi91t6to98zndtrrxzsqz9y87m5qztyqo#0'], + }, + metadata: { + }, + privateKeys: [ + { + crv : 'Ed25519', + d : 'hdSIwbQwVD-fNOVEgt-k3mMl44Ip1iPi58Ex6VDGxqY', + kty : 'OKP', + x : 'VYKm2SCIV9Vz3BRy-v5R9GHz3EOJCPvZ1_gP1e3XiB0', + kid : 'cyvOypa6k-4ffsRWcza37s5XVOh1kO9ICUeo1ZxHVM8', + alg : 'EdDSA', + }, + ], + }; + + const mockedBearerDid = await DidDht.import({ portableDid: mockedPortableDid, keyManager: testHarness.agent.keyManager }); + sinon.stub(DidDht, 'create').resolves(mockedBearerDid); + }); + + it('updates a DID in the store', async () => { + // Generate a new DID. + const did = await testHarness.agent.did.create({ method: 'jwk', tenant: testHarness.agent.agentDid.uri }); + const portableDid = await did.export(); + + const updateDid = { + ...portableDid, + document: { + ...portableDid.document, + service: [{ id: 'service1', type: 'example', serviceEndpoint: 'https://example.com' }] + } + }; + + // Update the DID. + await testHarness.agent.did.update({ portableDid: updateDid, tenant: testHarness.agent.agentDid.uri }); + + // get the updated DID + const updatedDid = await testHarness.agent.did.get({ didUri: did.uri, tenant: testHarness.agent.agentDid.uri }); + + // Verify the result. + expect(updatedDid).to.have.property('uri', did.uri); + expect(updatedDid).to.have.property('document'); + expect(updatedDid).to.have.property('metadata'); + + // Verify the updated document. + expect(updatedDid!.document).to.deep.equal(updateDid.document); + }); + + it('updates a DID DHT and publishes it by default', async () => { + const publishSpy = sinon.spy(DidDht, 'publish'); + + const did = await testHarness.agent.did.create({ method: 'dht', tenant: testHarness.agent.agentDid.uri }); + const portableDid = await did.export(); + + const updateDid = { + ...portableDid, + document: { + ...portableDid.document, + service: [{ id: 'service1', type: 'example', serviceEndpoint: 'https://example.com' }] + } + }; + + // Update the DID, publishes by default + await testHarness.agent.did.update({ portableDid: updateDid, tenant: testHarness.agent.agentDid.uri }); + + // get the updated DID + const updatedDid = await testHarness.agent.did.get({ didUri: did.uri, tenant: testHarness.agent.agentDid.uri }); + + // Verify the result. + expect(updatedDid).to.have.property('uri', did.uri); + expect(updatedDid).to.have.property('document'); + expect(updatedDid).to.have.property('metadata'); + + // Verify the updated document. + expect(updatedDid!.document).to.deep.equal(updateDid.document); + + // Verify publish was called + expect(publishSpy.called).to.be.true; + }); + + it('updates a DID DHT and does not publish it if publish is false', async () => { + const publishSpy = sinon.spy(DidDht, 'publish'); + + const did = await testHarness.agent.did.create({ method: 'dht', tenant: testHarness.agent.agentDid.uri }); + const portableDid = await did.export(); + + const updateDid = { + ...portableDid, + document: { + ...portableDid.document, + service: [{ id: 'service1', type: 'example', serviceEndpoint: 'https://example.com' }] + } + }; + + // Update the DID. + await testHarness.agent.did.update({ portableDid: updateDid, tenant: testHarness.agent.agentDid.uri, publish: false }); + + // get the updated DID + const updatedDid = await testHarness.agent.did.get({ didUri: did.uri, tenant: testHarness.agent.agentDid.uri }); + + // Verify the result. + expect(updatedDid).to.have.property('uri', did.uri); + expect(updatedDid).to.have.property('document'); + expect(updatedDid).to.have.property('metadata'); + + // Verify the updated document. + expect(updatedDid!.document).to.deep.equal(updateDid.document); + + // Verify publish was called + expect(publishSpy.called).to.be.false; + }); + + it('updates a DID under the tenant of the updated DID if tenant is not provided ', async () => { + // Generate a new DID. + const did = await testHarness.agent.did.create({ method: 'dht'}); + const portableDid = await did.export(); + + const updateDid = { + ...portableDid, + document: { + ...portableDid.document, + service: [{ id: 'service1', type: 'example', serviceEndpoint: 'https://example.com' }] + } + }; + + // Update the DID. + await testHarness.agent.did.update({ portableDid: updateDid }); + + // get the updated DID + const updatedDid = await testHarness.agent.did.get({ didUri: did.uri, tenant: did.uri }); + + // Verify the result. + expect(updatedDid).to.have.property('uri', did.uri); + expect(updatedDid).to.have.property('document'); + expect(updatedDid).to.have.property('metadata'); + + // Verify the updated document. + expect(updatedDid!.document).to.deep.equal(updateDid.document); + }); + + it('throws if DID does not exist in the store', async () => { + // Generate a new DID. + const did = await testHarness.agent.did.create({ method: 'dht'}); + const portableDid = await did.export(); + + const updateDid = { + ...portableDid, + uri : 'did:example:123', // change the uri to a different DID + document : { + ...portableDid.document, + service: [{ id: 'service1', type: 'example', serviceEndpoint: 'https://example.com' }] + } + }; + + try { + // Update the DID. + await testHarness.agent.did.update({ portableDid: updateDid, tenant: testHarness.agent.agentDid.uri }); + expect.fail('Expected an error to be thrown'); + } catch(error: any) { + expect(error.message).to.include('AgentDidApi: Could not update, DID not found'); + } + }); + + it('throws if the DID document is not updated', async () => { + // Generate a new DID. + const did = await testHarness.agent.did.create({ method: 'jwk', tenant: testHarness.agent.agentDid.uri }); + const portableDid = await did.export(); + + try { + // Update the DID. + await testHarness.agent.did.update({ portableDid, tenant: testHarness.agent.agentDid.uri }); + expect.fail('Expected an error to be thrown'); + } catch(error: any) { + expect(error.message).to.include('AgentDidApi: No changes detected, update aborted'); + } + }); }); }); }); diff --git a/packages/agent/tests/local-key-manager.spec.ts b/packages/agent/tests/local-key-manager.spec.ts index 8e9b64163..6b53232a3 100644 --- a/packages/agent/tests/local-key-manager.spec.ts +++ b/packages/agent/tests/local-key-manager.spec.ts @@ -1,9 +1,10 @@ import type { Jwk } from '@web5/crypto'; import type { BearerDid } from '@web5/dids'; +import sinon from 'sinon'; import { expect } from 'chai'; import { Convert } from '@web5/common'; -import { CryptoUtils } from '@web5/crypto'; +import { CryptoUtils, Ed25519 } from '@web5/crypto'; import type { Web5PlatformAgent } from '../src/types/agent.js'; @@ -106,6 +107,21 @@ describe('LocalKeyManager', () => { }); }); + describe('importKey()', () => { + it('imports a key and returns a key URI', async () => { + // generate a key and import it + const key = await Ed25519.generateKey(); + const keyUri = await testHarness.agent.keyManager.importKey({ key }); + + // fetch the key using the keyUri + const importedKey = await testHarness.agent.keyManager.exportKey({ keyUri }); + + // validate the key + expect(importedKey).to.exist; + expect(importedKey).to.deep.equal(key); + }); + }); + describe('exportKey()', () => { it('exports a private key as a JWK', async () => { const keyUri = await testHarness.agent.keyManager.generateKey({ algorithm: 'secp256k1' }); 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}`); + } + }); + }); }); }); }); diff --git a/packages/dids/src/bearer-did.ts b/packages/dids/src/bearer-did.ts index 120f94186..cdecee834 100644 --- a/packages/dids/src/bearer-did.ts +++ b/packages/dids/src/bearer-did.ts @@ -240,6 +240,7 @@ export class BearerDid { keyManager?: CryptoApi & KeyImporterExporter; portableDid: PortableDid; }): Promise { + // Get all verification methods from the given DID document, including embedded methods. const verificationMethods = getVerificationMethods({ didDocument: portableDid.document }); @@ -250,7 +251,13 @@ export class BearerDid { // If given, import the private key material into the key manager. for (let key of portableDid.privateKeys ?? []) { - await keyManager.importKey({ key }); + + // confirm th key does not already exist before importing it to avoid failures from the key manager + const keyUri = await keyManager.getKeyUri({ key }); + const keyExists = await keyManager.getPublicKey({ keyUri }).then(() => true).catch(() => false); + if (!keyExists) { + await keyManager.importKey({ key }); + } } // Validate that the key material for every verification method in the DID document is present diff --git a/packages/dids/tests/bearer-did.spec.ts b/packages/dids/tests/bearer-did.spec.ts index db0790bb8..7657b7504 100644 --- a/packages/dids/tests/bearer-did.spec.ts +++ b/packages/dids/tests/bearer-did.spec.ts @@ -447,5 +447,23 @@ describe('BearerDid', () => { expect(error.message).to.include('Key not found'); } }); + + it('does not attempt to import a key that is already in the key manager', async () => { + + // create a key manager + const keyManager = new LocalKeyManager(); + + // Import one of the private keys into the key manager + const privateKey = portableDid.privateKeys![0]; + await keyManager.importKey({ key: privateKey }); + + // spy on the importKey method + const importKeySpy = sinon.spy(keyManager, 'importKey'); + + // attempt to import the BearerDid with the key manager + const did = await BearerDid.import({ portableDid, keyManager }); + expect(did.uri).to.equal(portableDid.uri); + expect(importKeySpy.calledOnce).to.be.false; + }); }); }); \ No newline at end of file From 3f39bf1d14cf835a2959b5386a82ff7228d31e6c Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Thu, 17 Oct 2024 08:32:25 -0400 Subject: [PATCH 6/7] Ability to get and set DWN Service Endpoints from the IdentityAPI (#953) 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 --- .changeset/fair-pillows-notice.md | 9 + packages/agent/src/bearer-identity.ts | 2 +- packages/agent/src/identity-api.ts | 54 ++++++ packages/agent/src/utils.ts | 2 +- packages/agent/tests/identity-api.spec.ts | 220 ++++++++++++++++++++++ packages/dids/src/bearer-did.ts | 6 +- 6 files changed, 288 insertions(+), 5 deletions(-) create mode 100644 .changeset/fair-pillows-notice.md 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') { From 5120f6fd88e4883e1b8614b513f1aa826de7ce4c Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Mon, 21 Oct 2024 10:49:04 -0400 Subject: [PATCH 7/7] Ensure `protocolRole` is maintained between query/read and subscribe/read. (#954) Before this PR that were some inconsistencies with using `protocolRole`. There were instances where a user would query using a role but not be able to read the data of the given record because the role was not being applied. Same would happen during update/delete. This PR allows the READ operation to inherit the `protocolRole` used for a `query` or `subscribe` if it exists. Additionally it provides the user the ability to provide a different role when performing an `update` or `delete` operation. --- .changeset/many-suns-think.md | 8 + .changeset/slimy-mayflies-hide.md | 5 + packages/agent/src/utils.ts | 10 +- packages/agent/tests/utils.spec.ts | 44 ++- packages/api/src/dwn-api.ts | 2 + packages/api/src/record.ts | 20 +- packages/api/src/subscription-util.ts | 4 +- packages/api/tests/dwn-api.spec.ts | 198 ++++++++++++- .../fixtures/protocol-definitions/notes.json | 65 ++++ packages/api/tests/record.spec.ts | 280 +++++++++++++++++- 10 files changed, 621 insertions(+), 15 deletions(-) create mode 100644 .changeset/many-suns-think.md create mode 100644 .changeset/slimy-mayflies-hide.md create mode 100644 packages/api/tests/fixtures/protocol-definitions/notes.json diff --git a/.changeset/many-suns-think.md b/.changeset/many-suns-think.md new file mode 100644 index 000000000..02246e9b7 --- /dev/null +++ b/.changeset/many-suns-think.md @@ -0,0 +1,8 @@ +--- +"@web5/agent": patch +"@web5/identity-agent": patch +"@web5/proxy-agent": patch +"@web5/user-agent": patch +--- + +Add `getProtocolRole` util diff --git a/.changeset/slimy-mayflies-hide.md b/.changeset/slimy-mayflies-hide.md new file mode 100644 index 000000000..64357adce --- /dev/null +++ b/.changeset/slimy-mayflies-hide.md @@ -0,0 +1,5 @@ +--- +"@web5/api": patch +--- + +Ensure protocolRole is maintained between query/read and subscribe/read. diff --git a/packages/agent/src/utils.ts b/packages/agent/src/utils.ts index 2ab38757f..a72c9f467 100644 --- a/packages/agent/src/utils.ts +++ b/packages/agent/src/utils.ts @@ -1,5 +1,5 @@ import type { DidUrlDereferencer } from '@web5/dids'; -import { PaginationCursor, RecordsDeleteMessage, RecordsWriteMessage } from '@tbd54566975/dwn-sdk-js'; +import { Jws, PaginationCursor, RecordsDeleteMessage, RecordsWriteMessage } from '@tbd54566975/dwn-sdk-js'; import { Readable } from '@web5/common'; import { utils as didUtils } from '@web5/dids'; @@ -42,6 +42,14 @@ export function getRecordAuthor(record: RecordsWriteMessage | RecordsDeleteMessa return Message.getAuthor(record); } +/** + * Get the `protocolRole` string from the signature payload of the given RecordsWriteMessage or RecordsDeleteMessage. + */ +export function getRecordProtocolRole(message: RecordsWriteMessage | RecordsDeleteMessage): string | undefined { + const signaturePayload = Jws.decodePlainObjectPayload(message.authorization.signature); + return signaturePayload?.protocolRole; +} + export function isRecordsWrite(obj: unknown): obj is RecordsWrite { // Validate that the given value is an object. if (!obj || typeof obj !== 'object' || obj === null) return false; diff --git a/packages/agent/tests/utils.spec.ts b/packages/agent/tests/utils.spec.ts index ebefcf424..3751613e8 100644 --- a/packages/agent/tests/utils.spec.ts +++ b/packages/agent/tests/utils.spec.ts @@ -1,9 +1,18 @@ import { expect } from 'chai'; +import sinon from 'sinon'; -import { DateSort, Message, TestDataGenerator } from '@tbd54566975/dwn-sdk-js'; -import { getPaginationCursor, getRecordAuthor, getRecordMessageCid } from '../src/utils.js'; +import { DateSort, Jws, Message, TestDataGenerator } from '@tbd54566975/dwn-sdk-js'; +import { getPaginationCursor, getRecordAuthor, getRecordMessageCid, getRecordProtocolRole } from '../src/utils.js'; describe('Utils', () => { + beforeEach(() => { + sinon.restore(); + }); + + after(() => { + sinon.restore(); + }); + describe('getPaginationCursor', () => { it('should return a PaginationCursor object', async () => { // create a RecordWriteMessage object which is published @@ -84,4 +93,35 @@ describe('Utils', () => { expect(deleteAuthorFromFunction!).to.equal(recordsDeleteAuthor.did); }); }); + + describe('getRecordProtocolRole', () => { + it('gets a protocol role from a RecordsWrite', async () => { + const recordsWrite = await TestDataGenerator.generateRecordsWrite({ protocolRole: 'some-role' }); + const role = getRecordProtocolRole(recordsWrite.message); + expect(role).to.equal('some-role'); + }); + + it('gets a protocol role from a RecordsDelete', async () => { + const recordsDelete = await TestDataGenerator.generateRecordsDelete({ protocolRole: 'some-role' }); + const role = getRecordProtocolRole(recordsDelete.message); + expect(role).to.equal('some-role'); + }); + + it('returns undefined if no role is defined', async () => { + const recordsWrite = await TestDataGenerator.generateRecordsWrite(); + const writeRole = getRecordProtocolRole(recordsWrite.message); + expect(writeRole).to.be.undefined; + + const recordsDelete = await TestDataGenerator.generateRecordsDelete(); + const deleteRole = getRecordProtocolRole(recordsDelete.message); + expect(deleteRole).to.be.undefined; + }); + + it('returns undefined if decodedObject is undefined', async () => { + sinon.stub(Jws, 'decodePlainObjectPayload').returns(undefined); + const recordsWrite = await TestDataGenerator.generateRecordsWrite(); + const writeRole = getRecordProtocolRole(recordsWrite.message); + expect(writeRole).to.be.undefined; + }); + }); }); \ No newline at end of file diff --git a/packages/api/src/dwn-api.ts b/packages/api/src/dwn-api.ts index e91f21622..96d0e62a6 100644 --- a/packages/api/src/dwn-api.ts +++ b/packages/api/src/dwn-api.ts @@ -699,6 +699,7 @@ export class DwnApi { */ remoteOrigin : request.from, delegateDid : this.delegateDid, + protocolRole : agentRequest.messageParams.protocolRole, ...entry as DwnMessage[DwnInterface.RecordsWrite] }; const record = new Record(this.agent, recordOptions, this.permissionsApi); @@ -829,6 +830,7 @@ export class DwnApi { connectedDid : this.connectedDid, delegateDid : this.delegateDid, permissionsApi : this.permissionsApi, + protocolRole : request.message.protocolRole, request }) }; diff --git a/packages/api/src/record.ts b/packages/api/src/record.ts index 47b703c24..e93b2463f 100644 --- a/packages/api/src/record.ts +++ b/packages/api/src/record.ts @@ -21,6 +21,7 @@ import { SendDwnRequest, PermissionsApi, AgentPermissionsApi, + getRecordProtocolRole } from '@web5/agent'; import { Convert, isEmptyObject, NodeStream, removeUndefinedProperties, Stream } from '@web5/common'; @@ -183,6 +184,9 @@ export type RecordDeleteParams = { /** The timestamp indicating when the record was deleted. */ dateModified?: DwnMessageDescriptor[DwnInterface.RecordsDelete]['messageTimestamp']; + + /** The protocol role under which this record will be deleted. */ + protocolRole?: string; }; /** @@ -311,7 +315,6 @@ export class Record implements RecordModel { /** Tags of the record */ get tags() { return this._recordsWriteDescriptor?.tags; } - // Getters for for properties that depend on the current state of the Record. /** DID that is the logical author of the Record. */ get author(): string { return this._author; } @@ -703,7 +706,7 @@ export class Record implements RecordModel { * * @beta */ - async update({ dateModified, data, ...params }: RecordUpdateParams): Promise { + async update({ dateModified, data, protocolRole, ...params }: RecordUpdateParams): Promise { if (this.deleted) { throw new Error('Record: Cannot revive a deleted record.'); @@ -718,6 +721,7 @@ export class Record implements RecordModel { ...descriptor, ...params, parentContextId, + protocolRole : protocolRole ?? this._protocolRole, // Use the current protocolRole if not provided. messageTimestamp : dateModified, // Map Record class `dateModified` property to DWN SDK `messageTimestamp` recordId : this._recordId }; @@ -786,7 +790,7 @@ export class Record implements RecordModel { // Only update the local Record instance mutable properties if the record was successfully (over)written. this._authorization = responseMessage.authorization; - this._protocolRole = params.protocolRole; + this._protocolRole = updateMessage.protocolRole; mutableDescriptorProperties.forEach(property => { this._descriptor[property] = responseMessage.descriptor[property]; }); @@ -834,8 +838,11 @@ export class Record implements RecordModel { store }; - if (this.deleted) { - // if we have a delete message we can just use it + // Check to see if the provided protocolRole within the deleteParams is different from the current protocolRole. + const differentRole = deleteParams?.protocolRole ? getRecordProtocolRole(this.rawMessage) !== deleteParams.protocolRole : false; + // If the record is already in a deleted state but the protocolRole is different, we need to construct a delete message with the new protocolRole + // otherwise we can just use the existing delete message. + if (this.deleted && !differentRole) { deleteOptions.rawMessage = this.rawMessage as DwnMessage[DwnInterface.RecordsDelete]; } else { // otherwise we construct a delete message given the `RecordDeleteParams` @@ -843,6 +850,7 @@ export class Record implements RecordModel { prune : prune, recordId : this._recordId, messageTimestamp : dateModified, + protocolRole : deleteParams?.protocolRole ?? this._protocolRole // if no protocolRole is provided, use the current protocolRole }; } @@ -1023,7 +1031,7 @@ export class Record implements RecordModel { private async readRecordData({ target, isRemote }: { target: string, isRemote: boolean }) { const readRequest: ProcessDwnRequest = { author : this._connectedDid, - messageParams : { filter: { recordId: this.id } }, + messageParams : { filter: { recordId: this.id }, protocolRole: this._protocolRole }, messageType : DwnInterface.RecordsRead, target, }; diff --git a/packages/api/src/subscription-util.ts b/packages/api/src/subscription-util.ts index 5316733d0..88a6f16d2 100644 --- a/packages/api/src/subscription-util.ts +++ b/packages/api/src/subscription-util.ts @@ -9,10 +9,11 @@ export class SubscriptionUtil { /** * Creates a record subscription handler that can be used to process incoming {Record} messages. */ - static recordSubscriptionHandler({ agent, connectedDid, request, delegateDid, permissionsApi }:{ + static recordSubscriptionHandler({ agent, connectedDid, request, delegateDid, protocolRole, permissionsApi }:{ agent: Web5Agent; connectedDid: string; delegateDid?: string; + protocolRole?: string; permissionsApi?: PermissionsApi; request: RecordsSubscribeRequest; }): DwnRecordSubscriptionHandler { @@ -31,6 +32,7 @@ export class SubscriptionUtil { const record = new Record(agent, { ...message, ...recordOptions, + protocolRole, delegateDid: delegateDid, }, permissionsApi); diff --git a/packages/api/tests/dwn-api.spec.ts b/packages/api/tests/dwn-api.spec.ts index 7f332c000..e2fbb3730 100644 --- a/packages/api/tests/dwn-api.spec.ts +++ b/packages/api/tests/dwn-api.spec.ts @@ -3,13 +3,14 @@ import type { BearerDid } from '@web5/dids'; import sinon from 'sinon'; import { expect } from 'chai'; import { Web5UserAgent } from '@web5/user-agent'; -import { AgentPermissionsApi, DwnDateSort, DwnProtocolDefinition, getRecordAuthor, Oidc, PlatformAgentTestHarness, WalletConnect } from '@web5/agent'; +import { AgentPermissionsApi, DwnDateSort, DwnInterface, DwnProtocolDefinition, getRecordAuthor, Oidc, PlatformAgentTestHarness, ProcessDwnRequest, WalletConnect } from '@web5/agent'; import { DwnApi } from '../src/dwn-api.js'; import { testDwnUrl } from './utils/test-config.js'; import emailProtocolDefinition from './fixtures/protocol-definitions/email.json' assert { type: 'json' }; import photosProtocolDefinition from './fixtures/protocol-definitions/photos.json' assert { type: 'json' }; -import { DwnInterfaceName, DwnMethodName, Jws, PermissionsProtocol, Poller, Time } from '@tbd54566975/dwn-sdk-js'; +import notesProtocolDefinition from './fixtures/protocol-definitions/notes.json' assert { type: 'json' }; +import { DwnConstant, DwnInterfaceName, DwnMethodName, Jws, PermissionsProtocol, Poller, Time } from '@tbd54566975/dwn-sdk-js'; import { PermissionGrant } from '../src/permission-grant.js'; import { Record } from '../src/record.js'; import { TestDataGenerator } from './utils/test-data-generator.js'; @@ -2079,6 +2080,98 @@ describe('DwnApi', () => { expect(fooBarResult.records![0].id).to.equal(record.id); expect(fooBarResult.records![0].tags).to.deep.equal({ foo: 'bar' }); }); + + it('ensures that a protocolRole used to query is also used to read the data of the resulted records', async () => { + // scenario: Bob has a protocol where he can write notes and add friends who can query and read these notes + // Alice is a friend of Bob and she queries for the notes and reads the data of the notes + // the protocolRole used to query for the notes should also be used to read the data of the notes + + const protocol = { + ...notesProtocolDefinition, + protocol: 'http://example.com/notes' + TestDataGenerator.randomString(15) + }; + + // Bob configures the notes protocol for himself + const { status: bobProtocolStatus, protocol: bobProtocol } = await dwnBob.protocols.configure({ + message: { + definition: protocol + } + }); + expect(bobProtocolStatus.code).to.equal(202); + const { status: bobRemoteProtocolStatus } = await bobProtocol.send(bobDid.uri); + expect(bobRemoteProtocolStatus.code).to.equal(202); + + // Bob creates a few notes ensuring that the data is larger than the max encoded size + // that way the data will be requested with a separate `read` request + const recordData: Map = new Map(); + for (let i = 0; i < 3; i++) { + const data = TestDataGenerator.randomString(DwnConstant.maxDataSizeAllowedToBeEncoded + 1); + const { status: noteCreateStatus, record: noteRecord } = await dwnBob.records.create({ + data, + message: { + protocol : protocol.protocol, + protocolPath : 'note', + schema : protocol.types.note.schema, + dataFormat : 'text/plain', + } + }); + expect(noteCreateStatus.code).to.equal(202); + const { status: noteSendStatus } = await noteRecord.send(); + expect(noteSendStatus.code).to.equal(202); + recordData.set(noteRecord.id, data); + } + + // Bob makes Alice a `friend` to allow her to read and comment on his notes + const { status: friendCreateStatus, record: friendRecord} = await dwnBob.records.create({ + data : 'friend!', + message : { + recipient : aliceDid.uri, + protocol : protocol.protocol, + protocolPath : 'friend', + schema : protocol.types.friend.schema, + dataFormat : 'text/plain' + } + }); + expect(friendCreateStatus.code).to.equal(202); + const { status: bobFriendSendStatus } = await friendRecord.send(bobDid.uri); + expect(bobFriendSendStatus.code).to.equal(202); + + // alice uses the role to query for the available notes + const { status: notesQueryStatus, records: noteRecords } = await dwnAlice.records.query({ + from : bobDid.uri, + message : { + protocolRole : 'friend', + filter : { + protocol : protocol.protocol, + protocolPath : 'note' + } + } + }); + expect(notesQueryStatus.code).to.equal(200); + expect(noteRecords).to.exist; + expect(noteRecords).to.have.lengthOf(3); + + // spy on sendDwnRequest to ensure that the protocolRole is used to read the data of the notes + const sendDwnRequestSpy = sinon.spy(testHarness.agent, 'sendDwnRequest'); + + // confirm that it starts with 0 calls + expect(sendDwnRequestSpy.callCount).to.equal(0); + // Alice attempts to read the data of the notes, which should succeed + for (const record of noteRecords) { + const readResult = await record.data.text(); + const expectedData = recordData.get(record.id); + expect(readResult).to.equal(expectedData); + } + + // confirm that it was called 3 times + expect(sendDwnRequestSpy.callCount).to.equal(3); + + // confirm that the protocolRole was used to read the data of the notes + expect(sendDwnRequestSpy.getCalls().every(call => + call.args[0].messageType === DwnInterface.RecordsRead && + (call.args[0] as ProcessDwnRequest).messageParams.protocolRole === 'friend' + )).to.be.true; + }); }); }); @@ -2445,6 +2538,107 @@ describe('DwnApi', () => { expect(record.deleted).to.be.false; }); }); + + it('ensures that a protocolRole used to subscribe is also used to read the data of the resulted records', async () => { + // scenario: Bob has a protocol where he can write notes and add friends who can subscribe and read these notes + // When Alice subscribes to the notes protocol using the role, the role should also be used to read the data of the notes + + const protocol = { + ...notesProtocolDefinition, + protocol: 'http://example.com/notes' + TestDataGenerator.randomString(15) + }; + + // Bob configures the notes protocol for himself + const { status: bobProtocolStatus, protocol: bobProtocol } = await dwnBob.protocols.configure({ + message: { + definition: protocol + } + }); + expect(bobProtocolStatus.code).to.equal(202); + const { status: bobRemoteProtocolStatus } = await bobProtocol.send(bobDid.uri); + expect(bobRemoteProtocolStatus.code).to.equal(202); + + + // Bob makes Alice a `friend` to allow her to read and comment on his notes + const { status: friendCreateStatus, record: friendRecord} = await dwnBob.records.create({ + data : 'friend!', + message : { + recipient : aliceDid.uri, + protocol : protocol.protocol, + protocolPath : 'friend', + schema : protocol.types.friend.schema, + dataFormat : 'text/plain' + } + }); + expect(friendCreateStatus.code).to.equal(202); + const { status: bobFriendSendStatus } = await friendRecord.send(bobDid.uri); + expect(bobFriendSendStatus.code).to.equal(202); + + // Alice subscribes to the notes protocol using the role + const notes: Map = new Map(); + const { status: notesSubscribeStatus, subscription } = await dwnAlice.records.subscribe({ + from : bobDid.uri, + message : { + protocolRole : 'friend', + filter : { + protocol : protocol.protocol, + protocolPath : 'note' + } + }, + subscriptionHandler: (record) => { + // add to the notes map + notes.set(record.id, record); + } + }); + expect(notesSubscribeStatus.code).to.equal(200); + expect(subscription).to.exist; + + // Bob creates a few notes ensuring that the data is larger than the max encoded size + // that way the data will be requested with a separate `read` request + const recordData: Map = new Map(); + for (let i = 0; i < 3; i++) { + const data = TestDataGenerator.randomString(DwnConstant.maxDataSizeAllowedToBeEncoded + 1); + const { status: noteCreateStatus, record: noteRecord } = await dwnBob.records.create({ + data, + message: { + protocol : protocol.protocol, + protocolPath : 'note', + schema : protocol.types.note.schema, + dataFormat : 'text/plain', + } + }); + expect(noteCreateStatus.code).to.equal(202); + const { status: noteSendStatus } = await noteRecord.send(); + expect(noteSendStatus.code).to.equal(202); + recordData.set(noteRecord.id, data); + } + + // poll for the note records to be received + await Poller.pollUntilSuccessOrTimeout(async () => { + expect(notes.size).to.equal(3); + }); + + // spy on sendDwnRequest to ensure that the protocolRole is used to read the data of the notes + const sendDwnRequestSpy = sinon.spy(testHarness.agent, 'sendDwnRequest'); + + // confirm that it starts with 0 calls + expect(sendDwnRequestSpy.callCount).to.equal(0); + // Alice attempts to read the data of the notes, which should succeed + for (const record of notes.values()) { + const readResult = await record.data.text(); + const expectedData = recordData.get(record.id); + expect(readResult).to.equal(expectedData); + } + + // confirm that it was called 3 times + expect(sendDwnRequestSpy.callCount).to.equal(3); + + // confirm that the protocolRole was used to read the data of the notes + expect(sendDwnRequestSpy.getCalls().every(call => + call.args[0].messageType === DwnInterface.RecordsRead && + (call.args[0] as ProcessDwnRequest).messageParams.protocolRole === 'friend' + )).to.be.true; + }); }); }); diff --git a/packages/api/tests/fixtures/protocol-definitions/notes.json b/packages/api/tests/fixtures/protocol-definitions/notes.json new file mode 100644 index 000000000..cdea6f33c --- /dev/null +++ b/packages/api/tests/fixtures/protocol-definitions/notes.json @@ -0,0 +1,65 @@ +{ + "protocol": "http://notes-protocol.xyz", + "published": true, + "types": { + "note": { + "schema": "http://notes-protocol.xyz/schema/note", + "dataFormats": [ + "text/plain", + "application/json" + ] + }, + "comment": { + "schema": "http://notes-protocol.xyz/schema/comment", + "dataFormats": [ + "text/plain", + "application/json" + ] + }, + "friend" : { + "schema": "http://notes-protocol.xyz/schema/friend", + "dataFormats": [ + "text/plain", + "application/json" + ] + }, + "coAuthor" : { + "schema": "http://notes-protocol.xyz/schema/coAuthor", + "dataFormats": [ + "text/plain", + "application/json" + ] + } + }, + "structure": { + "friend" :{ + "$role": true + }, + "note": { + "coAuthor" : { + "$role": true + }, + "$actions": [ + { + "role": "friend", + "can": ["read", "query", "subscribe"] + }, + { + "role": "note/coAuthor", + "can": [ "co-update", "co-delete" ] + } + ], + "comment": { + "$actions": [ + { + "role": "friend", + "can": ["create", "update", "delete", "read", "query", "subscribe"] + }, { + "role": "note/coAuthor", + "can": ["create", "update", "delete", "co-delete", "read", "query", "subscribe"] + } + ] + } + } + } +} \ No newline at end of file diff --git a/packages/api/tests/record.spec.ts b/packages/api/tests/record.spec.ts index 7d147d633..8a14b5bb0 100644 --- a/packages/api/tests/record.spec.ts +++ b/packages/api/tests/record.spec.ts @@ -1,23 +1,24 @@ import type { BearerDid ,PortableDid } from '@web5/dids'; -import type { DwnMessageParams, DwnProtocolDefinition, DwnPublicKeyJwk, DwnSigner } from '@web5/agent'; +import type { DwnMessageParams, DwnProtocolDefinition, DwnPublicKeyJwk, DwnSigner, ProcessDwnRequest } from '@web5/agent'; import sinon from 'sinon'; import { expect } from 'chai'; import { NodeStream } from '@web5/common'; import { utils as didUtils } from '@web5/dids'; import { Web5UserAgent } from '@web5/user-agent'; -import { DwnConstant, DwnDateSort, DwnEncryptionAlgorithm, DwnInterface, DwnKeyDerivationScheme, dwnMessageConstructors, getRecordAuthor, Oidc, PlatformAgentTestHarness, WalletConnect } from '@web5/agent'; +import { DwnConstant, DwnDateSort, DwnEncryptionAlgorithm, DwnInterface, DwnKeyDerivationScheme, dwnMessageConstructors, getRecordAuthor, getRecordProtocolRole, Oidc, PlatformAgentTestHarness, WalletConnect } from '@web5/agent'; import { Record } from '../src/record.js'; import { DwnApi } from '../src/dwn-api.js'; import { dataToBlob } from '../src/utils.js'; import { testDwnUrl } from './utils/test-config.js'; import { TestDataGenerator } from './utils/test-data-generator.js'; import emailProtocolDefinition from './fixtures/protocol-definitions/email.json' assert { type: 'json' }; +import notesProtocolDefinition from './fixtures/protocol-definitions/notes.json' assert { type: 'json' }; // NOTE: @noble/secp256k1 requires globalThis.crypto polyfill for node.js <=18: https://github.com/paulmillr/noble-secp256k1/blob/main/README.md#usage // Remove when we move off of node.js v18 to v20, earliest possible time would be Oct 2023: https://github.com/nodejs/release#release-schedule import { webcrypto } from 'node:crypto'; -import { Jws, Message, Poller } from '@tbd54566975/dwn-sdk-js'; +import { Jws, Message, Poller, RecordsWrite } from '@tbd54566975/dwn-sdk-js'; import { Web5 } from '../src/web5.js'; // @ts-ignore if (!globalThis.crypto) globalThis.crypto = webcrypto; @@ -3086,6 +3087,145 @@ describe('Record', () => { // bob is the author expect(readResultAlice.record!.author).to.equal(bobDid.uri); }); + + it('updates a record using a different protocolRole than the one used when querying for/reading the record', async () => { + // scenario: Bob has a notes protocol that has friends who can read/query/subscribe to notes, but coAuthors that can update notes. + // When Alice uses her friend role to query for notes, she cannot update them with that same role. Instead she uses her coAuthor role update. + + const protocol = { + ...notesProtocolDefinition, + protocol: 'http://example.com/notes' + TestDataGenerator.randomString(15) + }; + + // Bob configures the notes protocol for himself + const { status: bobProtocolStatus, protocol: bobProtocol } = await dwnBob.protocols.configure({ + message: { + definition: protocol + } + }); + expect(bobProtocolStatus.code).to.equal(202); + const { status: bobProtocolSendStatus } = await bobProtocol.send(bobDid.uri); + expect(bobProtocolSendStatus.code).to.equal(202); + + // Alice must also configure the protocol to make updates. + // NOTE: This is not desireable and there is an issue to address this: + // https://github.com/TBD54566975/web5-js/issues/955 + const { status: aliceProtocolStatus, protocol: aliceProtocol } = await dwnAlice.protocols.configure({ + message: { + definition: protocol + } + }); + expect(aliceProtocolStatus.code).to.equal(202); + const { status: aliceProtocolSend } = await aliceProtocol.send(aliceDid.uri); + expect(aliceProtocolSend.code).to.equal(202); + + // Bob creates a few notes ensuring that the data is larger than the max encoded size + // that way the data will be requested with a separate `read` request + const records: Set = new Set(); + for (let i = 0; i < 3; i++) { + const data = TestDataGenerator.randomString(DwnConstant.maxDataSizeAllowedToBeEncoded + 1); + const { status: noteCreateStatus, record: noteRecord } = await dwnBob.records.create({ + data, + message: { + protocol : protocol.protocol, + protocolPath : 'note', + schema : protocol.types.note.schema, + dataFormat : 'text/plain', + } + }); + expect(noteCreateStatus.code).to.equal(202); + const { status: noteSendStatus } = await noteRecord.send(); + expect(noteSendStatus.code).to.equal(202); + records.add(noteRecord.id); + } + + // Bob makes Alice a `friend` to allow her to read and comment on his notes + const { status: friendCreateStatus, record: friendRecord} = await dwnBob.records.create({ + data : 'friend!', + message : { + recipient : aliceDid.uri, + protocol : protocol.protocol, + protocolPath : 'friend', + schema : protocol.types.friend.schema, + dataFormat : 'text/plain' + } + }); + expect(friendCreateStatus.code).to.equal(202); + const { status: bobFriendSendStatus } = await friendRecord.send(bobDid.uri); + expect(bobFriendSendStatus.code).to.equal(202); + + // Bob makes alice a 'coAuthor' of one of his notes + const aliceCoAuthorNoteId = records.keys().next().value; + const { status: coAuthorStatus, record: coAuthorRecord } = await dwnBob.records.create({ + data : aliceDid.uri, + message : { + parentContextId : aliceCoAuthorNoteId, + recipient : aliceDid.uri, + protocol : protocol.protocol, + protocolPath : 'note/coAuthor', + schema : protocol.types.coAuthor.schema, + dataFormat : 'text/plain' + } + }); + expect(coAuthorStatus.code).to.equal(202); + const { status: coAuthorSendStatus } = await coAuthorRecord.send(bobDid.uri); + expect(coAuthorSendStatus.code).to.equal(202); + + // Alice querying for bob's notes using her friend role + const { status: aliceQueryStatus, records: bobNotesAliceQuery } = await dwnAlice.records.query({ + from : bobDid.uri, + message : { + protocolRole : 'friend', + filter : { + protocol : protocol.protocol, + protocolPath : 'note', + } + } + }); + expect(aliceQueryStatus.code).to.equal(200); + expect(bobNotesAliceQuery).to.not.be.undefined; + expect(bobNotesAliceQuery.length).to.equal(records.size); + + // Alice looks for the record she has a co-author rule on + const coAuthorNote = bobNotesAliceQuery.find((record) => record.id === aliceCoAuthorNoteId); + expect(coAuthorNote).to.not.be.undefined; + + // Alice must import the record to be able to update it + // NOTE this should be removed after: https://github.com/TBD54566975/web5-js/issues/955 + const { status: importStatus } = await coAuthorNote.import(); + expect(importStatus.code).to.equal(202); + + // Alice updates the co-author note without providing a new role + const { status: updateStatus } = await coAuthorNote!.update({ data: 'updated note' }); + expect(updateStatus.code).to.equal(202); + + // spy on sendDwnRequest to ensure that the protocolRole is used to read the data of the notes + const sendDwnRequestSpy = sinon.spy(testHarness.agent, 'sendDwnRequest'); + + // confirm that it starts with 0 calls + expect(sendDwnRequestSpy.callCount).to.equal(0); + + // This is accepted locally but will fail when sending the update to the remote DWN + const { status: sendStatus } = await coAuthorNote.send(bobDid.uri); + expect(sendStatus.code).to.equal(401); + expect(sendDwnRequestSpy.callCount).to.equal(2); // the first call is for the initialWrite + let record = (sendDwnRequestSpy.secondCall.args[0] as ProcessDwnRequest).rawMessage; + let sendAuthorizationRole = getRecordProtocolRole(record); + expect(sendAuthorizationRole).to.equal('friend'); + + const { status: updateStatusCoAuthor } = await coAuthorNote!.update({ data: 'updated note', protocolRole: 'note/coAuthor' }); + expect(updateStatusCoAuthor.code).to.equal(202); + + sendDwnRequestSpy.resetHistory(); + + // Now update the record with the correct role + const { status: sendStatusCoAuthor } = await coAuthorNote.send(bobDid.uri); + expect(sendStatusCoAuthor.code).to.equal(202); + expect(sendDwnRequestSpy.callCount).to.equal(1); // the initialWrite was already sent and added to the sent-cache, only the update is sent + record = (sendDwnRequestSpy.firstCall.args[0] as ProcessDwnRequest).rawMessage; + sendAuthorizationRole = getRecordProtocolRole(record); + expect(sendAuthorizationRole).to.equal('note/coAuthor'); + }); }); describe('delete()', () => { @@ -3659,6 +3799,140 @@ describe('Record', () => { await subscription.close(); }); + + it('deletes a record using a different protocolRole than the one used when querying for/reading the record', async () => { + // scenario: Bob has a notes protocol that has friends who can read/query/subscribe to notes, but coAuthors that can update/delete notes. + // When Alice uses her friend role to query for notes, she cannot delete them with that same role. Instead she uses her coAuthor role to delete. + + const protocol = { + ...notesProtocolDefinition, + protocol: 'http://example.com/notes' + TestDataGenerator.randomString(15) + }; + + // Bob configures the notes protocol for himself + const { status: bobProtocolStatus, protocol: bobProtocol } = await dwnBob.protocols.configure({ + message: { + definition: protocol + } + }); + expect(bobProtocolStatus.code).to.equal(202); + const { status: bobProtocolSendStatus } = await bobProtocol.send(bobDid.uri); + expect(bobProtocolSendStatus.code).to.equal(202); + + // Alice must also configure the protocol to make updates. + // NOTE: This is not desireable and there is an issue to address this: + // https://github.com/TBD54566975/web5-js/issues/955 + const { status: aliceProtocolStatus, protocol: aliceProtocol } = await dwnAlice.protocols.configure({ + message: { + definition: protocol + } + }); + expect(aliceProtocolStatus.code).to.equal(202); + const { status: aliceProtocolSend } = await aliceProtocol.send(aliceDid.uri); + expect(aliceProtocolSend.code).to.equal(202); + + // Bob creates a few notes ensuring that the data is larger than the max encoded size + // that way the data will be requested with a separate `read` request + const records: Set = new Set(); + for (let i = 0; i < 3; i++) { + const data = TestDataGenerator.randomString(DwnConstant.maxDataSizeAllowedToBeEncoded + 1); + const { status: noteCreateStatus, record: noteRecord } = await dwnBob.records.create({ + data, + message: { + protocol : protocol.protocol, + protocolPath : 'note', + schema : protocol.types.note.schema, + dataFormat : 'text/plain', + } + }); + expect(noteCreateStatus.code).to.equal(202); + const { status: noteSendStatus } = await noteRecord.send(); + expect(noteSendStatus.code).to.equal(202); + records.add(noteRecord.id); + } + + // Bob makes Alice a `friend` to allow her to read and comment on his notes + const { status: friendCreateStatus, record: friendRecord} = await dwnBob.records.create({ + data : 'friend!', + message : { + recipient : aliceDid.uri, + protocol : protocol.protocol, + protocolPath : 'friend', + schema : protocol.types.friend.schema, + dataFormat : 'text/plain' + } + }); + expect(friendCreateStatus.code).to.equal(202); + const { status: bobFriendSendStatus } = await friendRecord.send(bobDid.uri); + expect(bobFriendSendStatus.code).to.equal(202); + + // Bob makes alice a 'coAuthor' of one of his notes + const aliceCoAuthorNoteId = records.keys().next().value; + const { status: coAuthorStatus, record: coAuthorRecord } = await dwnBob.records.create({ + data : aliceDid.uri, + message : { + parentContextId : aliceCoAuthorNoteId, + recipient : aliceDid.uri, + protocol : protocol.protocol, + protocolPath : 'note/coAuthor', + schema : protocol.types.coAuthor.schema, + dataFormat : 'text/plain' + } + }); + expect(coAuthorStatus.code).to.equal(202); + const { status: coAuthorSendStatus } = await coAuthorRecord.send(bobDid.uri); + expect(coAuthorSendStatus.code).to.equal(202); + + // Alice querying for bob's notes using her friend role + const { status: aliceQueryStatus, records: bobNotesAliceQuery } = await dwnAlice.records.query({ + from : bobDid.uri, + message : { + protocolRole : 'friend', + filter : { + protocol : protocol.protocol, + protocolPath : 'note', + } + } + }); + expect(aliceQueryStatus.code).to.equal(200); + expect(bobNotesAliceQuery).to.not.be.undefined; + expect(bobNotesAliceQuery.length).to.equal(records.size); + + // Alice looks for the record she has a co-author rule on + const coDeleteNote = bobNotesAliceQuery.find((record) => record.id === aliceCoAuthorNoteId); + expect(coDeleteNote).to.not.be.undefined; + + // spy on sendDwnRequest to ensure that the protocolRole is used to read the data of the notes + const sendDwnRequestSpy = sinon.spy(testHarness.agent, 'sendDwnRequest'); + + // confirm that it starts with 0 calls + expect(sendDwnRequestSpy.callCount).to.equal(0); + + const { status: deleteStatus } = await coDeleteNote.delete({ store: false }); + expect(deleteStatus.code).to.equal(202); + + const { status: sendDeleteStatus } = await coDeleteNote.send(bobDid.uri); + expect(sendDeleteStatus.code).to.equal(401); + + expect(sendDwnRequestSpy.callCount).to.equal(2); // the first call is for the initialWrite + let record = (sendDwnRequestSpy.secondCall.args[0] as ProcessDwnRequest).rawMessage; + let sendAuthorizationRole = getRecordProtocolRole(record); + expect(sendAuthorizationRole).to.equal('friend'); + + sendDwnRequestSpy.resetHistory(); + + // Now update the record with the correct role + const { status: updateStatusCoAuthor } = await coDeleteNote.delete({ protocolRole: 'note/coAuthor', store: false }); + expect(updateStatusCoAuthor.code).to.equal(202, `delete: ${updateStatusCoAuthor.detail}`); + + const { status: sendStatusCoAuthor } = await coDeleteNote.send(bobDid.uri); + expect(sendStatusCoAuthor.code).to.equal(202, `delete send: ${sendStatusCoAuthor.detail}`); + + expect(sendDwnRequestSpy.callCount).to.equal(1); // the initialWrite was already sent and added to the sent-cache, only the update is sent + record = (sendDwnRequestSpy.firstCall.args[0] as ProcessDwnRequest).rawMessage; + sendAuthorizationRole = getRecordProtocolRole(record); + expect(sendAuthorizationRole).to.equal('note/coAuthor'); + }); }); describe('store()', () => {