diff --git a/.changeset/fresh-olives-dance.md b/.changeset/fresh-olives-dance.md new file mode 100644 index 000000000..e611f3362 --- /dev/null +++ b/.changeset/fresh-olives-dance.md @@ -0,0 +1,5 @@ +--- +"@web5/agent": patch +--- + +Add requestPermissionsForProtocol helper method to connect module diff --git a/examples/wallet-connect.html b/examples/wallet-connect.html index eb3e813ab..ccaa699e7 100644 --- a/examples/wallet-connect.html +++ b/examples/wallet-connect.html @@ -128,35 +128,12 @@

Success

}, }; - const scopes = [ - { - interface: "Records", - method: "Write", - protocol: "http://profile-protocol.xyz", - }, - { - interface: "Records", - method: "Query", - protocol: "http://profile-protocol.xyz", - }, - { - interface: "Records", - method: "Read", - protocol: "http://profile-protocol.xyz", - }, - ]; - try { const { delegateDid } = await Web5.connect({ walletConnectOptions: { walletUri: "web5://connect", connectServerUrl: "http://localhost:3000/connect", - permissionRequests: [ - { - protocolDefinition: profileProtocol, - permissionScopes: scopes, - }, - ], + permissionRequests: [{ protocolDefinition: profileProtocol }], onWalletUriReady: generateQRCode, validatePin: async () => { goToPinScreen(); diff --git a/packages/agent/src/connect.ts b/packages/agent/src/connect.ts index c30d75908..fdaa35362 100644 --- a/packages/agent/src/connect.ts +++ b/packages/agent/src/connect.ts @@ -1,13 +1,16 @@ -import { CryptoUtils } from '@web5/crypto'; -import { DwnProtocolDefinition, DwnRecordsPermissionScope } from './index.js'; + +import type { PushedAuthResponse } from './oidc.js'; +import type { DwnPermissionScope, DwnProtocolDefinition, Web5ConnectAuthResponse } from './index.js'; + import { - Web5ConnectAuthResponse, Oidc, - type PushedAuthResponse, } from './oidc.js'; import { pollWithTtl } from './utils.js'; -import { DidJwk } from '@web5/dids'; + import { Convert } from '@web5/common'; +import { CryptoUtils } from '@web5/crypto'; +import { DidJwk } from '@web5/dids'; +import { DwnInterfaceName, DwnMethodName } from '@tbd54566975/dwn-sdk-js'; /** * Initiates the wallet connect process. Used when a client wants to obtain @@ -179,7 +182,74 @@ export type ConnectPermissionRequest = { protocolDefinition: DwnProtocolDefinition; /** The scope of the permissions being requested for the given protocol */ - permissionScopes: DwnRecordsPermissionScope[]; + permissionScopes: DwnPermissionScope[]; }; -export const WalletConnect = { initClient }; +export type Permission = 'write' | 'read' | 'delete' | 'query' | 'subscribe'; + +function createPermissionRequestForProtocol(definition: DwnProtocolDefinition, permissions: Permission[]): ConnectPermissionRequest { + const requests: DwnPermissionScope[] = []; + + // In order to enable sync, we must request permissions for `MessagesQuery`, `MessagesRead` and `MessagesSubscribe` + requests.push({ + protocol : definition.protocol, + interface : DwnInterfaceName.Messages, + method : DwnMethodName.Read, + }, { + protocol : definition.protocol, + interface : DwnInterfaceName.Messages, + method : DwnMethodName.Query, + }, { + protocol : definition.protocol, + interface : DwnInterfaceName.Messages, + method : DwnMethodName.Subscribe, + }); + + // We also request any additional permissions the user has requested for this protocol + for (const permission of permissions) { + switch (permission) { + case 'write': + requests.push({ + protocol : definition.protocol, + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + }); + break; + case 'read': + requests.push({ + protocol : definition.protocol, + interface : DwnInterfaceName.Records, + method : DwnMethodName.Read, + }); + break; + case 'delete': + requests.push({ + protocol : definition.protocol, + interface : DwnInterfaceName.Records, + method : DwnMethodName.Delete, + }); + break; + case 'query': + requests.push({ + protocol : definition.protocol, + interface : DwnInterfaceName.Records, + method : DwnMethodName.Query, + }); + break; + case 'subscribe': + requests.push({ + protocol : definition.protocol, + interface : DwnInterfaceName.Records, + method : DwnMethodName.Subscribe, + }); + break; + } + } + + return { + protocolDefinition : definition, + permissionScopes : requests, + }; +} + +export const WalletConnect = { initClient, createPermissionRequestForProtocol }; diff --git a/packages/agent/src/oidc.ts b/packages/agent/src/oidc.ts index d544c428b..a05873393 100644 --- a/packages/agent/src/oidc.ts +++ b/packages/agent/src/oidc.ts @@ -12,15 +12,13 @@ import { concatenateUrl } from './utils.js'; import { xchacha20poly1305 } from '@noble/ciphers/chacha'; import type { ConnectPermissionRequest } from './connect.js'; import { DidDocument, DidJwk, PortableDid, type BearerDid } from '@web5/dids'; -import { AgentDwnApi } from './dwn-api.js'; -import { - DwnInterfaceName, - DwnMethodName, - type PermissionScope, - type RecordsWriteMessage, +import type { + PermissionScope, + RecordsWriteMessage, } from '@tbd54566975/dwn-sdk-js'; import { DwnInterface, DwnProtocolDefinition } from './types/dwn.js'; import { AgentPermissionsApi } from './permissions-api.js'; +import type { Web5Agent } from './types/agent.js'; /** * Sent to an OIDC server to authorize a client. Allows clients @@ -616,14 +614,17 @@ function encryptAuthResponse({ async function createPermissionGrants( selectedDid: string, delegateBearerDid: BearerDid, - dwn: AgentDwnApi, - permissionsApi: AgentPermissionsApi, + agent: Web5Agent, scopes: PermissionScope[], - protocolUri: string ) { + + 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 const permissionGrants = await Promise.all( scopes.map((scope) => permissionsApi.createGrant({ + store : true, grantedTo : delegateBearerDid.uri, scope, dateExpires : '2040-06-25T16:09:16.693356Z', @@ -632,57 +633,23 @@ async function createPermissionGrants( ) ); - // Grant Messages Query and Messages Read for sync to work - permissionGrants.push( - await permissionsApi.createGrant({ - grantedTo : delegateBearerDid.uri, - scope : { - interface : DwnInterfaceName.Messages, - method : DwnMethodName.Query, - protocol : protocolUri, - }, - dateExpires : '2040-06-25T16:09:16.693356Z', - author : selectedDid, - }) - ); - permissionGrants.push( - await permissionsApi.createGrant({ - grantedTo : delegateBearerDid.uri, - scope : { - interface : DwnInterfaceName.Messages, - method : DwnMethodName.Read, - protocol : protocolUri, - }, - dateExpires : '2040-06-25T16:09:16.693356Z', - author : selectedDid, - }) - ); - const messagePromises = permissionGrants.map(async (grant) => { - // Quirk: we have to pull out encodedData out of the message the schema validator doesnt want it there + // Quirk: we have to pull out encodedData out of the message the schema validator doesn't want it there const { encodedData, ...rawMessage } = grant.message; const data = Convert.base64Url(encodedData).toUint8Array(); - const params = { + const { reply } = await agent.sendDwnRequest({ author : selectedDid, target : selectedDid, messageType : DwnInterface.RecordsWrite, dataStream : new Blob([data]), rawMessage, - }; - - const message = await dwn.processRequest(params); - const sent = await dwn.sendRequest(params); + }); - // TODO: cleanup all grants if one fails by deleting them from the DWN: https://github.com/TBD54566975/web5-js/issues/849 - if (message.reply.status.code !== 202) { - throw new Error( - `Could not process the message. Error details: ${message.reply.status.detail}` - ); - } - if (sent.reply.status.code !== 202) { + // 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) { throw new Error( - `Could not send the message. Error details: ${message.reply.status.detail}` + `Could not send the message. Error details: ${reply.status.detail}` ); } @@ -700,10 +667,10 @@ async function createPermissionGrants( */ async function prepareProtocols( selectedDid: string, - agentDwnApi: AgentDwnApi, + agent: Web5Agent, protocolDefinition: DwnProtocolDefinition ) { - const queryMessage = await agentDwnApi.processRequest({ + const queryMessage = await agent.processDwnRequest({ author : selectedDid, messageType : DwnInterface.ProtocolsQuery, target : selectedDid, @@ -711,7 +678,7 @@ async function prepareProtocols( }); if (queryMessage.reply.status.code === 404) { - const configureMessage = await agentDwnApi.processRequest({ + const configureMessage = await agent.processDwnRequest({ author : selectedDid, messageType : DwnInterface.ProtocolsConfigure, target : selectedDid, @@ -721,6 +688,19 @@ async function prepareProtocols( if (configureMessage.reply.status.code !== 202) { throw new Error(`Could not install protocol: ${configureMessage.reply.status.detail}`); } + + // send the configure message to the remote DWN so that the APP can immediately use it without waiting for a sync cycle from the wallet + const { reply: sendReply } = await agent.sendDwnRequest({ + author : selectedDid, + target : selectedDid, + messageType : DwnInterface.ProtocolsConfigure, + rawMessage : configureMessage.message, + }); + + // check if the message was sent successfully, if the remote returns 409 the message may have come through already via sync + if (sendReply.status.code !== 202 && sendReply.status.code !== 409) { + throw new Error(`Could not send protocol: ${sendReply.status.detail}`); + } } else if (queryMessage.reply.status.code !== 200) { throw new Error(`Could not fetch protcol: ${queryMessage.reply.status.detail}`); } @@ -739,24 +719,17 @@ async function submitAuthResponse( selectedDid: string, authRequest: Web5ConnectAuthRequest, randomPin: string, - agentDwnApi: AgentDwnApi, - agentPermissionsApi: AgentPermissionsApi + agent: Web5Agent, ) { const delegateBearerDid = await DidJwk.create(); const delegatePortableDid = await delegateBearerDid.export(); const delegateGrantPromises = authRequest.permissionRequests.map(async (permissionRequest) => { - await prepareProtocols(selectedDid, agentDwnApi, permissionRequest.protocolDefinition); - // TODO: validate to make sure the scopes and definition are assigned to the same protocol - const permissionGrants = await Oidc.createPermissionGrants( - selectedDid, - delegateBearerDid, - agentDwnApi, - agentPermissionsApi, - permissionRequest.permissionScopes, - permissionRequest.protocolDefinition.protocol - ); + const { protocolDefinition, permissionScopes } = permissionRequest; + + await prepareProtocols(selectedDid, agent, protocolDefinition); + const permissionGrants = await Oidc.createPermissionGrants(selectedDid, delegateBearerDid, agent, permissionScopes); return permissionGrants; }); diff --git a/packages/agent/src/sync-api.ts b/packages/agent/src/sync-api.ts index 3c7ea1089..29763f6b8 100644 --- a/packages/agent/src/sync-api.ts +++ b/packages/agent/src/sync-api.ts @@ -41,7 +41,7 @@ export class AgentSyncApi implements SyncEngine { this._syncEngine.agent = agent; } - public async registerIdentity(params: { did: string; options: SyncIdentityOptions }): Promise { + public async registerIdentity(params: { did: string; options?: SyncIdentityOptions }): Promise { await this._syncEngine.registerIdentity(params); } diff --git a/packages/agent/src/sync-engine-level.ts b/packages/agent/src/sync-engine-level.ts index 259667cb8..5908fba49 100644 --- a/packages/agent/src/sync-engine-level.ts +++ b/packages/agent/src/sync-engine-level.ts @@ -250,10 +250,13 @@ export class SyncEngineLevel implements SyncEngine { await pushQueue.batch(deleteOperations as any); } - public async registerIdentity({ did, options }: { did: string; options: SyncIdentityOptions }): Promise { + public async registerIdentity({ did, options }: { did: string; options?: SyncIdentityOptions }): Promise { // Get a reference to the `registeredIdentities` sublevel. const registeredIdentities = this._db.sublevel('registeredIdentities'); + // if no options are provided, we default to no delegateDid and all protocols (empty array) + options ??= { protocols: [] }; + // Add (or overwrite, if present) the Identity's DID as a registered identity. await registeredIdentities.put(did, JSON.stringify(options)); } diff --git a/packages/agent/src/types/sync.ts b/packages/agent/src/types/sync.ts index ee4dd3182..1fc5666d3 100644 --- a/packages/agent/src/types/sync.ts +++ b/packages/agent/src/types/sync.ts @@ -6,7 +6,7 @@ export type SyncIdentityOptions = { } export interface SyncEngine { agent: Web5PlatformAgent; - registerIdentity(params: { did: string, options: SyncIdentityOptions }): Promise; + registerIdentity(params: { did: string, options?: SyncIdentityOptions }): Promise; sync(direction?: 'push' | 'pull'): Promise; startSync(params: { interval: string }): Promise; stopSync(): void; diff --git a/packages/agent/tests/connect.spec.ts b/packages/agent/tests/connect.spec.ts index 3d6c0d20a..441f80bdd 100644 --- a/packages/agent/tests/connect.spec.ts +++ b/packages/agent/tests/connect.spec.ts @@ -4,7 +4,6 @@ import { CryptoUtils } from '@web5/crypto'; import { type BearerDid, DidDht, DidJwk, PortableDid } from '@web5/dids'; import { Convert } from '@web5/common'; import { - DelegateGrant, Oidc, type Web5ConnectAuthRequest, type Web5ConnectAuthResponse, @@ -12,8 +11,9 @@ import { import { PlatformAgentTestHarness } from '../src/test-harness.js'; import { TestAgent } from './utils/test-agent.js'; import { testDwnUrl } from './utils/test-config.js'; -import { BearerIdentity, DwnProtocolDefinition, DwnProtocolPermissionScope, DwnResponse, WalletConnect } from '../src/index.js'; -import { RecordsPermissionScope, type PermissionScope } from '@tbd54566975/dwn-sdk-js'; +import { BearerIdentity, DwnProtocolDefinition, WalletConnect } from '../src/index.js'; +import { RecordsPermissionScope } from '@tbd54566975/dwn-sdk-js'; +import { DwnInterfaceName, DwnMethodName } from '@tbd54566975/dwn-sdk-js'; describe('web5 connect', function () { this.timeout(20000); @@ -197,6 +197,7 @@ describe('web5 connect', function () { }); after(async () => { + sinon.restore(); await testHarness.clearStorage(); await testHarness.closeStorage(); }); @@ -288,13 +289,11 @@ describe('web5 connect', function () { const results = await Oidc.createPermissionGrants( providerIdentity.did.uri, delegateBearerDid, - testHarness.agent.dwn, - testHarness.agent.permissions, - permissionScopes, - protocolDefinition.protocol + testHarness.agent, + permissionScopes ); - const scopesRequestedPlusTwoDefaultScopes = permissionScopes.length + 2; - expect(results).to.have.lengthOf(scopesRequestedPlusTwoDefaultScopes); + const scopesRequested = permissionScopes.length; + expect(results).to.have.lengthOf(scopesRequested); expect(results[0]).to.be.a('object'); }); @@ -392,8 +391,7 @@ describe('web5 connect', function () { selectedDid, authRequest, randomPin, - testHarness.agent.dwn, - testHarness.agent.permissions + testHarness.agent ); expect(fetchSpy.calledOnce).to.be.true; }); @@ -490,4 +488,83 @@ describe('web5 connect', function () { expect(results?.delegatePortableDid).to.be.an('object'); }); }); + + describe('createPermissionRequestForProtocol', () => { + it('should add sync permissions to all requests', async () => { + const protocol:DwnProtocolDefinition = { + published : true, + protocol : 'https://exmaple.org/protocols/social', + types : { + note: { + schema : 'https://example.org/schemas/note', + dataFormats : [ 'application/json', 'text/plain' ], + } + }, + structure: { + note: {} + } + }; + + const permissionRequests = WalletConnect.createPermissionRequestForProtocol(protocol, []); + + expect(permissionRequests.protocolDefinition).to.deep.equal(protocol); + expect(permissionRequests.permissionScopes.length).to.equal(3); // only includes the sync permissions + expect(permissionRequests.permissionScopes.find(scope => scope.interface === DwnInterfaceName.Messages && scope.method === DwnMethodName.Read)).to.not.be.undefined; + expect(permissionRequests.permissionScopes.find(scope => scope.interface === DwnInterfaceName.Messages && scope.method === DwnMethodName.Query)).to.not.be.undefined; + expect(permissionRequests.permissionScopes.find(scope => scope.interface === DwnInterfaceName.Messages && scope.method === DwnMethodName.Subscribe)).to.not.be.undefined; + }); + + it('should add requested permissions to the request', async () => { + const protocol:DwnProtocolDefinition = { + published : true, + protocol : 'https://exmaple.org/protocols/social', + types : { + note: { + schema : 'https://example.org/schemas/note', + dataFormats : [ 'application/json', 'text/plain' ], + } + }, + structure: { + note: {} + } + }; + + const permissionRequests = WalletConnect.createPermissionRequestForProtocol(protocol, ['write', 'read']); + + expect(permissionRequests.protocolDefinition).to.deep.equal(protocol); + + // the 3 sync permissions plus the 2 requested permissions + expect(permissionRequests.permissionScopes.length).to.equal(5); + expect(permissionRequests.permissionScopes.find(scope => scope.interface === DwnInterfaceName.Records && scope.method === DwnMethodName.Read)).to.not.be.undefined; + expect(permissionRequests.permissionScopes.find(scope => scope.interface === DwnInterfaceName.Records && scope.method === DwnMethodName.Write)).to.not.be.undefined; + }); + + it('supports requesting `read`, `write`, `delete`, `query` and `subscribe` permissions', async () => { + const protocol:DwnProtocolDefinition = { + published : true, + protocol : 'https://exmaple.org/protocols/social', + types : { + note: { + schema : 'https://example.org/schemas/note', + dataFormats : [ 'application/json', 'text/plain' ], + } + }, + structure: { + note: {} + } + }; + + const permissionRequests = WalletConnect.createPermissionRequestForProtocol(protocol, ['write', 'read', 'delete', 'query', 'subscribe']); + + expect(permissionRequests.protocolDefinition).to.deep.equal(protocol); + + // the 3 sync permissions plus the 5 requested permissions + expect(permissionRequests.permissionScopes.length).to.equal(8); + expect(permissionRequests.permissionScopes.find(scope => scope.interface === DwnInterfaceName.Records && scope.method === DwnMethodName.Read)).to.not.be.undefined; + expect(permissionRequests.permissionScopes.find(scope => scope.interface === DwnInterfaceName.Records && scope.method === DwnMethodName.Write)).to.not.be.undefined; + expect(permissionRequests.permissionScopes.find(scope => scope.interface === DwnInterfaceName.Records && scope.method === DwnMethodName.Delete)).to.not.be.undefined; + expect(permissionRequests.permissionScopes.find(scope => scope.interface === DwnInterfaceName.Records && scope.method === DwnMethodName.Query)).to.not.be.undefined; + expect(permissionRequests.permissionScopes.find(scope => scope.interface === DwnInterfaceName.Records && scope.method === DwnMethodName.Subscribe)).to.not.be.undefined; + }); + }); }); diff --git a/packages/agent/tests/rpc-client.spec.ts b/packages/agent/tests/rpc-client.spec.ts index 1d03fb534..8bb207c38 100644 --- a/packages/agent/tests/rpc-client.spec.ts +++ b/packages/agent/tests/rpc-client.spec.ts @@ -69,6 +69,10 @@ describe('RPC Clients', () => { alice = await TestDataGenerator.generateDidKeyPersona(); }); + after(() => { + sinon.restore(); + }); + it('returns available transports', async () => { const httpOnlyClient = new Web5RpcClient(); @@ -262,6 +266,10 @@ describe('RPC Clients', () => { let alice: Persona; let client: HttpWeb5RpcClient; + after(() => { + sinon.restore(); + }); + beforeEach(async () => { sinon.restore(); @@ -353,6 +361,10 @@ describe('RPC Clients', () => { dwnUrl.protocol = dwnUrl.protocol === 'http:' ? 'ws:' : 'wss:'; const socketDwnUrl = dwnUrl.toString(); + after(() => { + sinon.restore(); + }); + beforeEach(async () => { sinon.restore(); diff --git a/packages/agent/tests/sync-engine-level.spec.ts b/packages/agent/tests/sync-engine-level.spec.ts index 7365148a6..35fa68a08 100644 --- a/packages/agent/tests/sync-engine-level.spec.ts +++ b/packages/agent/tests/sync-engine-level.spec.ts @@ -12,6 +12,7 @@ import { testDwnUrl } from './utils/test-config.js'; import { SyncEngineLevel } from '../src/sync-engine-level.js'; import { PlatformAgentTestHarness } from '../src/test-harness.js'; import { Convert } from '@web5/common'; +import { AbstractLevel } from 'abstract-level'; let testDwnUrls: string[] = [testDwnUrl]; @@ -162,6 +163,7 @@ describe('SyncEngineLevel', () => { }); after(async () => { + sinon.restore(); await testHarness.clearStorage(); await testHarness.closeStorage(); }); @@ -353,10 +355,7 @@ describe('SyncEngineLevel', () => { // Register Alice's DID to be synchronized. await testHarness.agent.sync.registerIdentity({ - did : alice.did.uri, - options : { - protocols: [] - } + did: alice.did.uri, }); // Execute Sync to pull all records from Alice's remote DWN to Alice's local DWN. @@ -431,10 +430,7 @@ describe('SyncEngineLevel', () => { // Register Alice's DID to be synchronized. await testHarness.agent.sync.registerIdentity({ - did : alice.did.uri, - options : { - protocols: [] - } + did: alice.did.uri, }); // Execute Sync to push and pull all records from Alice's remote DWN to Alice's local DWN. @@ -471,10 +467,7 @@ describe('SyncEngineLevel', () => { it('throws if sync is attempted while an interval sync is running', async () => { // Register Alice's DID to be synchronized. await testHarness.agent.sync.registerIdentity({ - did : alice.did.uri, - options : { - protocols: [] - } + did: alice.did.uri, }); // start the sync engine with an interval of 10 seconds @@ -1057,6 +1050,21 @@ describe('SyncEngineLevel', () => { expect(localBarRecords.reply.status.code).to.equal(200); expect(localBarRecords.reply.entries).to.have.length(0); }); + + it('defaults to all protocols and undefined delegate if no options are provided', async () => { + // spy on AbstractLevel put + const abstractLevelPut = sinon.spy(AbstractLevel.prototype, 'put'); + + // register identity without any options + await testHarness.agent.sync.registerIdentity({ + did: alice.did.uri + }); + + const registerIdentitiesPutCall = abstractLevelPut.args[0]; + const options = JSON.parse(registerIdentitiesPutCall[1] as string); + // confirm that without options the options are set to an empty protocol array + expect(options).to.deep.equal({ protocols: [] }); + }); }); describe('pull()', () => { @@ -1112,10 +1120,7 @@ describe('SyncEngineLevel', () => { // Register Alice's DID to be synchronized. await testHarness.agent.sync.registerIdentity({ - did : alice.did.uri, - options : { - protocols: [] - } + did: alice.did.uri, }); // Execute Sync to pull all records from Alice's remote DWN to Alice's local DWN. @@ -1208,10 +1213,7 @@ describe('SyncEngineLevel', () => { // Register Alice's DID to be synchronized. await testHarness.agent.sync.registerIdentity({ - did : alice.did.uri, - options : { - protocols: [] - } + did: alice.did.uri, }); // Execute Sync to pull all records from Alice's remote DWNs @@ -1337,10 +1339,7 @@ describe('SyncEngineLevel', () => { // Register Alice's DID to be synchronized. await testHarness.agent.sync.registerIdentity({ - did : alice.did.uri, - options : { - protocols: [] - } + did: alice.did.uri, }); // spy on sendDwnRequest to the remote DWN @@ -1561,10 +1560,7 @@ describe('SyncEngineLevel', () => { // Register Alice's DID to be synchronized. await testHarness.agent.sync.registerIdentity({ - did : alice.did.uri, - options : { - protocols: [] - } + did: alice.did.uri, }); // Execute Sync to pull all records from Alice's remote DWN to Alice's local DWN. @@ -1626,10 +1622,7 @@ describe('SyncEngineLevel', () => { // register alice await testHarness.agent.sync.registerIdentity({ - did : alice.did.uri, - options : { - protocols: [] - } + did: alice.did.uri, }); // create a remote record @@ -1720,18 +1713,12 @@ describe('SyncEngineLevel', () => { // Register Alice's DID to be synchronized. await testHarness.agent.sync.registerIdentity({ - did : alice.did.uri, - options : { - protocols: [] - } + did: alice.did.uri, }); // Register Bob's DID to be synchronized. await testHarness.agent.sync.registerIdentity({ - did : bob.did.uri, - options : { - protocols: [] - } + did: bob.did.uri, }); // Execute Sync to pull all records from Alice's and Bob's remove DWNs to their local DWNs. @@ -1814,10 +1801,7 @@ describe('SyncEngineLevel', () => { // Register Alice's DID to be synchronized. await testHarness.agent.sync.registerIdentity({ - did : alice.did.uri, - options : { - protocols: [] - } + did: alice.did.uri, }); // Execute Sync to pull all records from Alice's remote DWN to Alice's local DWN. @@ -1917,10 +1901,7 @@ describe('SyncEngineLevel', () => { // Register Alice's DID to be synchronized. await testHarness.agent.sync.registerIdentity({ - did : alice.did.uri, - options : { - protocols: [] - } + did: alice.did.uri, }); // Execute Sync to pull all records from Alice's remote DWNs @@ -1956,10 +1937,7 @@ describe('SyncEngineLevel', () => { // Register Alice's DID to be synchronized. await testHarness.agent.sync.registerIdentity({ - did : alice.did.uri, - options : { - protocols: [] - } + did: alice.did.uri, }); // scenario: The messageCids returned from the local eventLog contains a Cid that already exists in the remote DWN. @@ -2251,10 +2229,7 @@ describe('SyncEngineLevel', () => { // Register Alice's DID to be synchronized. await testHarness.agent.sync.registerIdentity({ - did : alice.did.uri, - options : { - protocols: [] - } + did: alice.did.uri, }); // Execute Sync to push all records from Alice's local DWN to Alice's remote DWN. @@ -2315,10 +2290,7 @@ describe('SyncEngineLevel', () => { //register alice await testHarness.agent.sync.registerIdentity({ - did : alice.did.uri, - options : { - protocols: [] - } + did: alice.did.uri, }); // create a local record @@ -2407,18 +2379,12 @@ describe('SyncEngineLevel', () => { // Register Alice's DID to be synchronized. await testHarness.agent.sync.registerIdentity({ - did : alice.did.uri, - options : { - protocols: [] - } + did: alice.did.uri, }); // Register Bob's DID to be synchronized. await testHarness.agent.sync.registerIdentity({ - did : bob.did.uri, - options : { - protocols: [] - } + did: bob.did.uri, }); // Execute Sync to push all records from Alice's and Bob's local DWNs to their remote DWNs. @@ -2451,10 +2417,7 @@ describe('SyncEngineLevel', () => { describe('startSync()', () => { it('calls sync() in each interval', async () => { await testHarness.agent.sync.registerIdentity({ - did : alice.did.uri, - options : { - protocols: [] - } + did: alice.did.uri, }); const syncSpy = sinon.stub(SyncEngineLevel.prototype, 'sync'); @@ -2473,10 +2436,7 @@ describe('SyncEngineLevel', () => { it('does not call sync() again until a sync round finishes', async () => { await testHarness.agent.sync.registerIdentity({ - did : alice.did.uri, - options : { - protocols: [] - } + did: alice.did.uri, }); const clock = sinon.useFakeTimers(); diff --git a/packages/api/src/web5.ts b/packages/api/src/web5.ts index 112579de1..6aba6ecef 100644 --- a/packages/api/src/web5.ts +++ b/packages/api/src/web5.ts @@ -9,8 +9,10 @@ import type { DelegateGrant, DwnDataEncodedRecordsWriteMessage, DwnMessagesPermissionScope, + DwnProtocolDefinition, DwnRecordsPermissionScope, HdIdentityVault, + Permission, WalletConnectOptions, Web5Agent, } from '@web5/agent'; @@ -35,6 +37,15 @@ export type DidCreateOptions = { dwnEndpoints?: string[]; } +export type ConnectPermissionRequest = { + protocolDefinition: DwnProtocolDefinition; + permissions?: Permission[]; +} + +export type ConnectOptions = Omit & { + permissionRequests: ConnectPermissionRequest[]; +} + /** Optional overrides that can be provided when calling {@link Web5.connect}. */ export type Web5ConnectOptions = { /** @@ -42,7 +53,7 @@ export type Web5ConnectOptions = { * This param currently will not work in apps that are currently connected. * It must only be invoked at registration with a reset and empty DWN and agent. */ - walletConnectOptions?: WalletConnectOptions; + walletConnectOptions?: ConnectOptions; /** * Provide a {@link Web5Agent} implementation. Defaults to creating a local @@ -276,7 +287,15 @@ export class Web5 { // No connected identity found and connectOptions are provided, attempt to import a delegated DID from an external wallet try { - const { delegatePortableDid, connectedDid, delegateGrants: returnedGrants } = await WalletConnect.initClient(walletConnectOptions); + const { permissionRequests, ...connectOptions } = walletConnectOptions; + const walletPermissionRequests = permissionRequests.map(({ protocolDefinition, permissions }) => WalletConnect.createPermissionRequestForProtocol(protocolDefinition, permissions ?? [ + 'read', 'write', 'delete', 'query', 'subscribe' + ])); + + const { delegatePortableDid, connectedDid, delegateGrants: returnedGrants } = await WalletConnect.initClient({ + ...connectOptions, + permissionRequests: walletPermissionRequests, + }); delegateGrants = returnedGrants; // Import the delegated DID as an Identity in the User Agent. diff --git a/packages/api/tests/web5.spec.ts b/packages/api/tests/web5.spec.ts index ef1bdb522..ceb4791e9 100644 --- a/packages/api/tests/web5.spec.ts +++ b/packages/api/tests/web5.spec.ts @@ -793,6 +793,145 @@ describe('web5 api', () => { expect(startSyncSpy.args[0][0].interval).to.equal('1m'); }); + + + + it('should request all permissions for a protocol if no specific permissions are provided', async () => { + + sinon.stub(Web5UserAgent, 'create').resolves(testHarness.agent as Web5UserAgent); + + // spy on the WalletConnect createPermissionRequestForProtocol method + const requestPermissionsSpy = sinon.spy(WalletConnect, 'createPermissionRequestForProtocol'); + + // We throw and spy on the initClient method to avoid the actual WalletConnect initialization + // but to still be able to spy on the passed parameters + sinon.stub(WalletConnect, 'initClient').throws('Error'); + + // stub the cleanUpIdentity method to avoid actual cleanup + sinon.stub(Web5 as any, 'cleanUpIdentity').resolves(); + + const protocolDefinition: DwnProtocolDefinition = { + protocol : 'https://example.com/test-protocol', + published : true, + types : { + foo : {}, + bar : {} + }, + structure: { + foo: { + bar: {} + } + } + }; + + try { + + await Web5.connect({ + walletConnectOptions: { + connectServerUrl : 'https://connect.example.com', + walletUri : 'https://wallet.example.com', + validatePin : async () => { return '1234'; }, + onWalletUriReady : (_walletUri: string) => {}, + permissionRequests : [{ protocolDefinition }] + } + }); + + expect.fail('Should have thrown an error'); + } catch(error: any) { + // we expect an error because we stubbed the initClient method to throw it + expect(error.message).to.include('Sinon-provided Error'); + + // The `createPermissionRequestForProtocol` method should have been called once for the provided protocol + expect(requestPermissionsSpy.callCount).to.equal(1); + const call = requestPermissionsSpy.getCall(0); + + // since no explicit permissions were provided, all permissions should be requested + expect(call.args[1]).to.have.members([ + 'read', 'write', 'delete', 'query', 'subscribe' + ]); + } + }); + + it('should only request the specified permissions for a protocol', async () => { + + sinon.stub(Web5UserAgent, 'create').resolves(testHarness.agent as Web5UserAgent); + + // spy on the WalletConnect createPermissionRequestForProtocol method + const requestPermissionsSpy = sinon.spy(WalletConnect, 'createPermissionRequestForProtocol'); + + // We throw and spy on the initClient method to avoid the actual WalletConnect initialization + // but to still be able to spy on the passed parameters + sinon.stub(WalletConnect, 'initClient').throws('Error'); + + // stub the cleanUpIdentity method to avoid actual cleanup + sinon.stub(Web5 as any, 'cleanUpIdentity').resolves(); + + const protocol1Definition: DwnProtocolDefinition = { + protocol : 'https://example.com/test-protocol-1', + published : true, + types : { + foo : {}, + bar : {} + }, + structure: { + foo: { + bar: {} + } + } + }; + + const protocol2Definition: DwnProtocolDefinition = { + protocol : 'https://example.com/test-protocol-2', + published : true, + types : { + foo : {}, + bar : {} + }, + structure: { + foo: { + bar: {} + } + } + }; + + + try { + + await Web5.connect({ + walletConnectOptions: { + connectServerUrl : 'https://connect.example.com', + walletUri : 'https://wallet.example.com', + validatePin : async () => { return '1234'; }, + onWalletUriReady : (_walletUri: string) => {}, + permissionRequests : [ + { protocolDefinition: protocol1Definition }, // no permissions provided, expect all permissions to be requested + { protocolDefinition: protocol2Definition, permissions: ['read', 'write'] } // only read and write permissions provided + ] + } + }); + + expect.fail('Should have thrown an error'); + } catch(error: any) { + // we expect an error because we stubbed the initClient method to throw it + expect(error.message).to.include('Sinon-provided Error'); + + // The `createPermissionRequestForProtocol` method should have been called once for each provided request + expect(requestPermissionsSpy.callCount).to.equal(2); + const call1 = requestPermissionsSpy.getCall(0); + + // since no explicit permissions were provided for the first protocol, all permissions should be requested + expect(call1.args[1]).to.have.members([ + 'read', 'write', 'delete', 'query', 'subscribe' + ]); + + const call2 = requestPermissionsSpy.getCall(1); + + // only the provided permissions should be requested for the second protocol + expect(call2.args[1]).to.have.members([ + 'read', 'write' + ]); + } + }); }); describe('registration', () => {