From 7fc1f1dc1913fb3c77516db0f6ba2c55787bc84b Mon Sep 17 00:00:00 2001 From: Liran Cohen Date: Tue, 3 Sep 2024 11:26:33 -0400 Subject: [PATCH] add grant to Record class methods (#859) This PR fixes a bug where the `Record` class method which is retrieved with queries, reads, and writes did not respect the delegated permission state. The `Record` class now accepts an optional PermissionsApi so that it can share the TTL cache with a caller. If one is not provided a new one is instantiated from the agent that is passed. Took advantage of removing the specific `CachedPermissions` class and wrapped it into the `PermissionsApi` interface. Refactored some unnecessary abstractions when fetching the grant, added tests for missing cases and subscription cases. --- .changeset/green-plums-swim.md | 8 + .changeset/plenty-brooms-suffer.md | 5 + packages/agent/src/cached-permissions.ts | 66 -- packages/agent/src/index.ts | 1 - packages/agent/src/permissions-api.ts | 51 +- packages/agent/src/sync-engine-level.ts | 20 +- packages/agent/src/test-harness.ts | 1 + packages/agent/src/types/permissions.ts | 21 +- .../agent/tests/cached-permissions.spec.ts | 237 ---- packages/agent/tests/permissions-api.spec.ts | 181 +++ .../agent/tests/sync-engine-level.spec.ts | 1 + packages/api/src/dwn-api.ts | 159 ++- packages/api/src/record.ts | 190 ++- packages/api/src/subscription-util.ts | 13 +- packages/api/src/web5.ts | 1 - packages/api/tests/did-api.spec.ts | 20 + packages/api/tests/dwn-api.spec.ts | 448 ++++--- packages/api/tests/permission-grant.spec.ts | 3 +- packages/api/tests/permission-request.spec.ts | 3 +- packages/api/tests/protocol.spec.ts | 3 +- packages/api/tests/record.spec.ts | 1041 ++++++++++++++--- 21 files changed, 1712 insertions(+), 761 deletions(-) create mode 100644 .changeset/green-plums-swim.md create mode 100644 .changeset/plenty-brooms-suffer.md delete mode 100644 packages/agent/src/cached-permissions.ts delete mode 100644 packages/agent/tests/cached-permissions.spec.ts diff --git a/.changeset/green-plums-swim.md b/.changeset/green-plums-swim.md new file mode 100644 index 000000000..445cf7c0d --- /dev/null +++ b/.changeset/green-plums-swim.md @@ -0,0 +1,8 @@ +--- +"@web5/agent": minor +"@web5/identity-agent": minor +"@web5/proxy-agent": minor +"@web5/user-agent": minor +--- + +Tefactor getting permissions for grants into a single Permission API interface diff --git a/.changeset/plenty-brooms-suffer.md b/.changeset/plenty-brooms-suffer.md new file mode 100644 index 000000000..ac843e3f4 --- /dev/null +++ b/.changeset/plenty-brooms-suffer.md @@ -0,0 +1,5 @@ +--- +"@web5/api": patch +--- + +Consume single PermissionApi for dealing with permissions, fix bug for Record class not fetching delegate permissions for request. diff --git a/packages/agent/src/cached-permissions.ts b/packages/agent/src/cached-permissions.ts deleted file mode 100644 index 81c11e29e..000000000 --- a/packages/agent/src/cached-permissions.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { TtlCache } from '@web5/common'; -import { AgentPermissionsApi } from './permissions-api.js'; -import { Web5Agent } from './types/agent.js'; -import { PermissionGrantEntry } from './types/permissions.js'; -import { DwnInterface } from './types/dwn.js'; - -export class CachedPermissions { - - /** the default value for whether a fetch is cached or not */ - private cachedDefault: boolean; - - /** Holds the instance of {@link AgentPermissionsApi} that helps when dealing with permissions protocol records */ - private permissionsApi: AgentPermissionsApi; - - /** cache for fetching a permission {@link PermissionGrant}, keyed by a specific MessageType and protocol */ - private cachedPermissions: TtlCache = new TtlCache({ ttl: 60 * 1000 }); - - constructor({ agent, cachedDefault }:{ agent: Web5Agent, cachedDefault?: boolean }) { - this.permissionsApi = new AgentPermissionsApi({ agent }); - this.cachedDefault = cachedDefault ?? false; - } - - public async getPermission({ connectedDid, delegateDid, delegate, messageType, protocol, cached = this.cachedDefault }: { - connectedDid: string; - delegateDid: string; - messageType: T; - protocol?: string; - cached?: boolean; - delegate?: boolean; - }): Promise { - // Currently we only support finding grants based on protocols - // A different approach may be necessary when we introduce `protocolPath` and `contextId` specific impersonation - const cacheKey = [ connectedDid, delegateDid, messageType, protocol ].join('~'); - const cachedGrant = cached ? this.cachedPermissions.get(cacheKey) : undefined; - if (cachedGrant) { - return cachedGrant; - } - - const permissionGrants = await this.permissionsApi.fetchGrants({ - author : delegateDid, - target : delegateDid, - grantor : connectedDid, - grantee : delegateDid, - }); - - // get the delegate grants that match the messageParams and are associated with the connectedDid as the grantor - const grant = await AgentPermissionsApi.matchGrantFromArray( - connectedDid, - delegateDid, - { messageType, protocol }, - permissionGrants, - delegate - ); - - if (!grant) { - throw new Error(`CachedPermissions: No permissions found for ${messageType}: ${protocol}`); - } - - this.cachedPermissions.set(cacheKey, grant); - return grant; - } - - public async clear(): Promise { - this.cachedPermissions.clear(); - } -} \ No newline at end of file diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 7f7457575..83971304d 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -9,7 +9,6 @@ export type * from './types/vc.js'; export * from './agent-did-resolver-cache.js'; export * from './bearer-identity.js'; -export * from './cached-permissions.js'; export * from './crypto-api.js'; export * from './did-api.js'; export * from './dwn-api.js'; diff --git a/packages/agent/src/permissions-api.ts b/packages/agent/src/permissions-api.ts index 1d2d85cc8..86ae5e343 100644 --- a/packages/agent/src/permissions-api.ts +++ b/packages/agent/src/permissions-api.ts @@ -1,12 +1,15 @@ import { PermissionGrant, PermissionGrantData, PermissionRequestData, PermissionRevocationData, PermissionsProtocol } from '@tbd54566975/dwn-sdk-js'; import { Web5Agent } from './types/agent.js'; import { DwnDataEncodedRecordsWriteMessage, DwnInterface, DwnMessageParams, DwnMessagesPermissionScope, DwnPermissionGrant, DwnPermissionRequest, DwnPermissionScope, DwnProtocolPermissionScope, DwnRecordsPermissionScope, ProcessDwnRequest } from './types/dwn.js'; -import { Convert } from '@web5/common'; -import { CreateGrantParams, CreateRequestParams, CreateRevocationParams, FetchPermissionRequestParams, FetchPermissionsParams, IsGrantRevokedParams, PermissionGrantEntry, PermissionRequestEntry, PermissionRevocationEntry, PermissionsApi } from './types/permissions.js'; +import { Convert, TtlCache } from '@web5/common'; +import { CreateGrantParams, CreateRequestParams, CreateRevocationParams, FetchPermissionRequestParams, FetchPermissionsParams, GetPermissionParams, IsGrantRevokedParams, PermissionGrantEntry, PermissionRequestEntry, PermissionRevocationEntry, PermissionsApi } from './types/permissions.js'; import { isRecordsType } from './dwn-api.js'; export class AgentPermissionsApi implements PermissionsApi { + /** cache for fetching a permission {@link PermissionGrant}, keyed by a specific MessageType and protocol */ + private _cachedPermissions: TtlCache = new TtlCache({ ttl: 60 * 1000 }); + private _agent?: Web5Agent; get agent(): Web5Agent { @@ -24,6 +27,46 @@ export class AgentPermissionsApi implements PermissionsApi { this._agent = agent; } + async getPermissionForRequest({ + connectedDid, + delegateDid, + delegate, + messageType, + protocol, + cached = false + }: GetPermissionParams): Promise { + // Currently we only support finding grants based on protocols + // A different approach may be necessary when we introduce `protocolPath` and `contextId` specific impersonation + const cacheKey = [ connectedDid, delegateDid, messageType, protocol ].join('~'); + const cachedGrant = cached ? this._cachedPermissions.get(cacheKey) : undefined; + if (cachedGrant) { + return cachedGrant; + } + + const permissionGrants = await this.fetchGrants({ + author : delegateDid, + target : delegateDid, + grantor : connectedDid, + grantee : delegateDid, + }); + + // get the delegate grants that match the messageParams and are associated with the connectedDid as the grantor + const grant = await AgentPermissionsApi.matchGrantFromArray( + connectedDid, + delegateDid, + { messageType, protocol }, + permissionGrants, + delegate + ); + + if (!grant) { + throw new Error(`CachedPermissions: No permissions found for ${messageType}: ${protocol}`); + } + + this._cachedPermissions.set(cacheKey, grant); + return grant; + } + async fetchGrants({ author, target, @@ -269,6 +312,10 @@ export class AgentPermissionsApi implements PermissionsApi { return { message: dataEncodedMessage }; } + async clear():Promise { + this._cachedPermissions.clear(); + } + /** * Matches the appropriate grant from an array of grants based on the provided parameters. * diff --git a/packages/agent/src/sync-engine-level.ts b/packages/agent/src/sync-engine-level.ts index 72336b3b3..15affed2b 100644 --- a/packages/agent/src/sync-engine-level.ts +++ b/packages/agent/src/sync-engine-level.ts @@ -22,7 +22,8 @@ import type { Web5Agent, Web5PlatformAgent } from './types/agent.js'; import { DwnInterface } from './types/dwn.js'; import { getDwnServiceEndpointUrls, isRecordsWrite } from './utils.js'; -import { CachedPermissions } from './cached-permissions.js'; +import { PermissionsApi } from './types/permissions.js'; +import { AgentPermissionsApi } from './permissions-api.js'; export type SyncEngineLevelParams = { agent?: Web5PlatformAgent; @@ -64,7 +65,7 @@ export class SyncEngineLevel implements SyncEngine { /** * An instance of the `AgentPermissionsApi` that is used to interact with permissions grants used during sync */ - private _cachedPermissionsApi: CachedPermissions; + private _permissionsApi: PermissionsApi;; private _db: AbstractLevel; private _syncIntervalId?: ReturnType; @@ -72,7 +73,7 @@ export class SyncEngineLevel implements SyncEngine { constructor({ agent, dataPath, db }: SyncEngineLevelParams) { this._agent = agent; - this._cachedPermissionsApi = new CachedPermissions({ agent: agent as Web5Agent, cachedDefault: true }); + this._permissionsApi = new AgentPermissionsApi({ agent: agent as Web5Agent }); this._db = (db) ? db : new Level(dataPath ?? 'DATA/AGENT/SYNC_STORE'); this._ulidFactory = monotonicFactory(); } @@ -93,11 +94,11 @@ export class SyncEngineLevel implements SyncEngine { set agent(agent: Web5PlatformAgent) { this._agent = agent; - this._cachedPermissionsApi = new CachedPermissions({ agent: agent as Web5Agent, cachedDefault: true }); + this._permissionsApi = new AgentPermissionsApi({ agent: agent as Web5Agent }); } public async clear(): Promise { - await this._cachedPermissionsApi.clear(); + await this._permissionsApi.clear(); await this._db.clear(); } @@ -133,11 +134,12 @@ export class SyncEngineLevel implements SyncEngine { let granteeDid: string | undefined; if (delegateDid) { try { - const messagesReadGrant = await this._cachedPermissionsApi.getPermission({ + const messagesReadGrant = await this._permissionsApi.getPermissionForRequest({ connectedDid : did, messageType : DwnInterface.MessagesRead, delegateDid, protocol, + cached : true }); permissionGrantId = messagesReadGrant.grant.id; @@ -402,11 +404,12 @@ export class SyncEngineLevel implements SyncEngine { if (delegateDid) { // fetch the grants for the delegate DID try { - const messagesQueryGrant = await this._cachedPermissionsApi.getPermission({ + const messagesQueryGrant = await this._permissionsApi.getPermissionForRequest({ connectedDid : did, messageType : DwnInterface.MessagesQuery, delegateDid, protocol, + cached : true }); permissionGrantId = messagesQueryGrant.grant.id; @@ -469,11 +472,12 @@ export class SyncEngineLevel implements SyncEngine { let permissionGrantId: string | undefined; if (delegateDid) { try { - const messagesReadGrant = await this._cachedPermissionsApi.getPermission({ + const messagesReadGrant = await this._permissionsApi.getPermissionForRequest({ connectedDid : author, messageType : DwnInterface.MessagesRead, delegateDid, protocol, + cached : true }); permissionGrantId = messagesReadGrant.grant.id; diff --git a/packages/agent/src/test-harness.ts b/packages/agent/src/test-harness.ts index ec2e89d4d..a98b7c9f2 100644 --- a/packages/agent/src/test-harness.ts +++ b/packages/agent/src/test-harness.ts @@ -94,6 +94,7 @@ export class PlatformAgentTestHarness { await this.dwnResumableTaskStore.clear(); await this.syncStore.clear(); await this.vaultStore.clear(); + await this.agent.permissions.clear(); this.dwnStores.clear(); // Reset the indexes and caches for the Agent's DWN data stores. diff --git a/packages/agent/src/types/permissions.ts b/packages/agent/src/types/permissions.ts index 99758179b..03b9cac7f 100644 --- a/packages/agent/src/types/permissions.ts +++ b/packages/agent/src/types/permissions.ts @@ -1,4 +1,4 @@ -import { DwnDataEncodedRecordsWriteMessage, DwnPermissionGrant, DwnPermissionRequest, DwnPermissionScope } from './dwn.js'; +import { DwnDataEncodedRecordsWriteMessage, DwnInterface, DwnPermissionGrant, DwnPermissionRequest, DwnPermissionScope } from './dwn.js'; export type FetchPermissionsParams = { author: string; @@ -63,7 +63,21 @@ export type CreateRevocationParams = { description?: string; } +export type GetPermissionParams = { + connectedDid: string; + delegateDid: string; + messageType: DwnInterface; + protocol?: string; + cached?: boolean; + delegate?: boolean; +} + export interface PermissionsApi { + /** + * Get the permission grant for a given author, target, and protocol. To be used when authoring delegated requests. + */ + getPermissionForRequest: (params: GetPermissionParams) => Promise; + /** * Fetch all grants for a given author and target, optionally filtered by a specific grantee, grantor, or protocol. */ @@ -93,4 +107,9 @@ export interface PermissionsApi { * Create a new permission revocation, optionally storing it in the DWN. */ createRevocation(params: CreateRevocationParams): Promise; + + /** + * Clears the cache of matched permissions. + */ + clear: () => Promise; } diff --git a/packages/agent/tests/cached-permissions.spec.ts b/packages/agent/tests/cached-permissions.spec.ts deleted file mode 100644 index 134e3b56c..000000000 --- a/packages/agent/tests/cached-permissions.spec.ts +++ /dev/null @@ -1,237 +0,0 @@ -import sinon from 'sinon'; -import { expect } from 'chai'; -import { AgentPermissionsApi } from '../src/permissions-api.js'; -import { PlatformAgentTestHarness } from '../src/test-harness.js'; -import { TestAgent } from './utils/test-agent.js'; -import { BearerDid } from '@web5/dids'; - -import { DwnInterfaceName, DwnMethodName, Time } from '@tbd54566975/dwn-sdk-js'; -import { CachedPermissions, DwnInterface } from '../src/index.js'; -import { Convert } from '@web5/common'; - -describe('CachedPermissions', () => { - let permissions: AgentPermissionsApi; - let testHarness: PlatformAgentTestHarness; - let aliceDid: BearerDid; - let bobDid: BearerDid; - - before(async () => { - testHarness = await PlatformAgentTestHarness.setup({ - agentClass : TestAgent, - agentStores : 'dwn' - }); - }); - - after(async () => { - sinon.restore(); - await testHarness.clearStorage(); - await testHarness.closeStorage(); - }); - - beforeEach(async () => { - sinon.restore(); - await testHarness.clearStorage(); - await testHarness.createAgentDid(); - - // Create an "alice" Identity to author the DWN messages. - const alice = await testHarness.agent.identity.create({ didMethod: 'jwk', metadata: { name: 'Alice' } }); - await testHarness.agent.identity.manage({ portableIdentity: await alice.export() }); - aliceDid = alice.did; - - const bob = await testHarness.agent.identity.create({ didMethod: 'jwk', metadata: { name: 'Bob' } }); - await testHarness.agent.identity.manage({ portableIdentity: await bob.export() }); - bobDid = bob.did; - - permissions = new AgentPermissionsApi({ agent: testHarness.agent }); - }); - - describe('cachedDefault', () => { - it('caches permissions by default if defaultCache is set to true', async () => { - // create a permission grant to fetch - const messagesQueryGrant = await permissions.createGrant({ - store : true, - author : aliceDid.uri, - grantedTo : bobDid.uri, - dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), - scope : { - interface : DwnInterfaceName.Messages, - method : DwnMethodName.Query, - } - }); - - // store the grant as owner from bob so that it can be fetched - const { encodedData, ...messagesQueryGrantMessage } = messagesQueryGrant.message; - const grantReply = await testHarness.agent.processDwnRequest({ - target : bobDid.uri, - author : bobDid.uri, - signAsOwner : true, - messageType : DwnInterface.RecordsWrite, - rawMessage : messagesQueryGrantMessage, - dataStream : new Blob([ Convert.base64Url(encodedData).toUint8Array() ]) - }); - expect(grantReply.reply.status.code).to.equal(202); - - const permissionGrantsApiSpy = sinon.spy(AgentPermissionsApi.prototype, 'fetchGrants'); - - // with defaultCache set to true - const cachedPermissions = new CachedPermissions({ agent: testHarness.agent, cachedDefault: true }); - - // fetch the grant - let fetchedMessagesQueryGrant = await cachedPermissions.getPermission({ - connectedDid : aliceDid.uri, - delegateDid : bobDid.uri, - messageType : DwnInterface.MessagesQuery, - }); - expect(fetchedMessagesQueryGrant).to.not.be.undefined; - expect(fetchedMessagesQueryGrant.message.recordId).to.equal(messagesQueryGrant.message.recordId); - - // confirm that the permission was fetched from the API - expect(permissionGrantsApiSpy.calledOnce).to.be.true; - permissionGrantsApiSpy.resetHistory(); - - // fetch the grant again to confirm that it was cached - fetchedMessagesQueryGrant = await cachedPermissions.getPermission({ - connectedDid : aliceDid.uri, - delegateDid : bobDid.uri, - messageType : DwnInterface.MessagesQuery, - }); - expect(fetchedMessagesQueryGrant).to.not.be.undefined; - expect(fetchedMessagesQueryGrant.message.recordId).to.equal(messagesQueryGrant.message.recordId); - - // confirm that the permission was not fetched again from the API - expect(permissionGrantsApiSpy.called).to.be.false; - - // confirm that the permissions is fetched from teh api if cache is set to false on a single call - fetchedMessagesQueryGrant = await cachedPermissions.getPermission({ - connectedDid : aliceDid.uri, - delegateDid : bobDid.uri, - messageType : DwnInterface.MessagesQuery, - cached : false, - }); - expect(fetchedMessagesQueryGrant).to.not.be.undefined; - expect(fetchedMessagesQueryGrant.message.recordId).to.equal(messagesQueryGrant.message.recordId); - - // confirm that the permission was fetched from the API - expect(permissionGrantsApiSpy.calledOnce).to.be.true; - }); - - it('does not cache permission by default defaultCache is set to false', async () => { - // create a permission grant to fetch - const messagesQueryGrant = await permissions.createGrant({ - store : true, - author : aliceDid.uri, - grantedTo : bobDid.uri, - dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), - scope : { - interface : DwnInterfaceName.Messages, - method : DwnMethodName.Query, - } - }); - - // store the grant as owner from bob so that it can be fetched - const { encodedData, ...messagesQueryGrantMessage } = messagesQueryGrant.message; - const grantReply = await testHarness.agent.processDwnRequest({ - target : bobDid.uri, - author : bobDid.uri, - signAsOwner : true, - messageType : DwnInterface.RecordsWrite, - rawMessage : messagesQueryGrantMessage, - dataStream : new Blob([ Convert.base64Url(encodedData).toUint8Array() ]) - }); - expect(grantReply.reply.status.code).to.equal(202); - - const permissionGrantsApiSpy = sinon.spy(AgentPermissionsApi.prototype, 'fetchGrants'); - - // with defaultCache set to false by default - const cachedPermissions = new CachedPermissions({ agent: testHarness.agent }); - - // fetch the grant - let fetchedMessagesQueryGrant = await cachedPermissions.getPermission({ - connectedDid : aliceDid.uri, - delegateDid : bobDid.uri, - messageType : DwnInterface.MessagesQuery, - }); - expect(fetchedMessagesQueryGrant).to.not.be.undefined; - expect(fetchedMessagesQueryGrant.message.recordId).to.equal(messagesQueryGrant.message.recordId); - - // confirm that the permission was fetched from the API - expect(permissionGrantsApiSpy.calledOnce).to.be.true; - permissionGrantsApiSpy.resetHistory(); - - // fetch the grant again to confirm that it was cached - fetchedMessagesQueryGrant = await cachedPermissions.getPermission({ - connectedDid : aliceDid.uri, - delegateDid : bobDid.uri, - messageType : DwnInterface.MessagesQuery, - }); - expect(fetchedMessagesQueryGrant).to.not.be.undefined; - expect(fetchedMessagesQueryGrant.message.recordId).to.equal(messagesQueryGrant.message.recordId); - - // confirm that the permission was fetched a second time from the API - expect(permissionGrantsApiSpy.called).to.be.true; - permissionGrantsApiSpy.resetHistory(); - - // confirm that the permissions is not fetched from the api if cache is set to true on a single call - fetchedMessagesQueryGrant = await cachedPermissions.getPermission({ - connectedDid : aliceDid.uri, - delegateDid : bobDid.uri, - messageType : DwnInterface.MessagesQuery, - cached : true, - }); - expect(fetchedMessagesQueryGrant).to.not.be.undefined; - expect(fetchedMessagesQueryGrant.message.recordId).to.equal(messagesQueryGrant.message.recordId); - - // confirm that the permission was fetched from the API - expect(permissionGrantsApiSpy.calledOnce).to.be.false; - }); - }); - - describe('getPermission', () => { - it('throws an error if no permissions are found', async () => { - const cachedPermissions = new CachedPermissions({ agent: testHarness.agent }); - - try { - await cachedPermissions.getPermission({ - connectedDid : aliceDid.uri, - delegateDid : bobDid.uri, - messageType : DwnInterface.MessagesQuery, - }); - expect.fail('Expected an error to be thrown'); - } catch(error: any) { - expect(error.message).to.equal('CachedPermissions: No permissions found for MessagesQuery: undefined'); - } - - // create a permission grant to fetch - const messagesQueryGrant = await permissions.createGrant({ - store : true, - author : aliceDid.uri, - grantedTo : bobDid.uri, - dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), - scope : { - interface : DwnInterfaceName.Messages, - method : DwnMethodName.Query, - } - }); - - // store the grant as owner from bob so that it can be fetched - const { encodedData, ...messagesQueryGrantMessage } = messagesQueryGrant.message; - const grantReply = await testHarness.agent.processDwnRequest({ - target : bobDid.uri, - author : bobDid.uri, - signAsOwner : true, - messageType : DwnInterface.RecordsWrite, - rawMessage : messagesQueryGrantMessage, - dataStream : new Blob([ Convert.base64Url(encodedData).toUint8Array() ]) - }); - expect(grantReply.reply.status.code).to.equal(202); - - // fetch the grant - const fetchedMessagesQueryGrant = await cachedPermissions.getPermission({ - connectedDid : aliceDid.uri, - delegateDid : bobDid.uri, - messageType : DwnInterface.MessagesQuery, - }); - expect(fetchedMessagesQueryGrant.message.recordId).to.equal(messagesQueryGrant.message.recordId); - }); - }); -}); \ No newline at end of file diff --git a/packages/agent/tests/permissions-api.spec.ts b/packages/agent/tests/permissions-api.spec.ts index bb925d8a3..df4b9e49f 100644 --- a/packages/agent/tests/permissions-api.spec.ts +++ b/packages/agent/tests/permissions-api.spec.ts @@ -7,11 +7,13 @@ import { BearerDid } from '@web5/dids'; import { DwnInterfaceName, DwnMethodName, Time } from '@tbd54566975/dwn-sdk-js'; import { DwnInterface, DwnPermissionGrant, DwnPermissionScope, Web5PlatformAgent } from '../src/index.js'; +import { Convert } from '@web5/common'; describe('AgentPermissionsApi', () => { let testHarness: PlatformAgentTestHarness; let aliceDid: BearerDid; + let bobDid: BearerDid; before(async () => { testHarness = await PlatformAgentTestHarness.setup({ @@ -35,6 +37,10 @@ describe('AgentPermissionsApi', () => { const alice = await testHarness.agent.identity.create({ didMethod: 'jwk', metadata: { name: 'Alice' } }); await testHarness.agent.identity.manage({ portableIdentity: await alice.export() }); aliceDid = alice.did; + + const bob = await testHarness.agent.identity.create({ didMethod: 'jwk', metadata: { name: 'Bob' } }); + await testHarness.agent.identity.manage({ portableIdentity: await bob.export() }); + bobDid = bob.did; }); describe('get agent', () => { @@ -54,6 +60,181 @@ describe('AgentPermissionsApi', () => { }); }); + describe('getPermission', () => { + it('throws an error if no permissions are found', async () => { + try { + await testHarness.agent.permissions.getPermissionForRequest({ + connectedDid : aliceDid.uri, + delegateDid : bobDid.uri, + messageType : DwnInterface.MessagesQuery, + }); + expect.fail('Expected an error to be thrown'); + } catch(error: any) { + expect(error.message).to.equal('CachedPermissions: No permissions found for MessagesQuery: undefined'); + } + + // create a permission grant to fetch + const messagesQueryGrant = await testHarness.agent.permissions.createGrant({ + store : true, + author : aliceDid.uri, + grantedTo : bobDid.uri, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + scope : { + interface : DwnInterfaceName.Messages, + method : DwnMethodName.Query, + } + }); + + // store the grant as owner from bob so that it can be fetched + const { encodedData, ...messagesQueryGrantMessage } = messagesQueryGrant.message; + const grantReply = await testHarness.agent.processDwnRequest({ + target : bobDid.uri, + author : bobDid.uri, + signAsOwner : true, + messageType : DwnInterface.RecordsWrite, + rawMessage : messagesQueryGrantMessage, + dataStream : new Blob([ Convert.base64Url(encodedData).toUint8Array() ]) + }); + expect(grantReply.reply.status.code).to.equal(202); + + // fetch the grant + const fetchedMessagesQueryGrant = await testHarness.agent.permissions.getPermissionForRequest({ + connectedDid : aliceDid.uri, + delegateDid : bobDid.uri, + messageType : DwnInterface.MessagesQuery, + }); + expect(fetchedMessagesQueryGrant.message.recordId).to.equal(messagesQueryGrant.message.recordId); + }); + + it('caches and returns the permission grant', async () => { + // create a RecordsWrite grant from alice to bob + const protocolUri = 'http://example.com/protocol'; + const recordsWriteGrant = await testHarness.agent.permissions.createGrant({ + store : true, + author : aliceDid.uri, + grantedTo : bobDid.uri, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : protocolUri + } + }); + expect(recordsWriteGrant).to.exist; + + // store as bob + const { encodedData, ...recordsWriteGrantMessage } = recordsWriteGrant.message; + const grantReply = await testHarness.agent.processDwnRequest({ + target : bobDid.uri, + author : bobDid.uri, + signAsOwner : true, + messageType : DwnInterface.RecordsWrite, + rawMessage : recordsWriteGrantMessage, + dataStream : new Blob([ Convert.base64Url(encodedData).toUint8Array() ]) + }); + expect(grantReply.reply.status.code).to.equal(202); + + // spy on fetchGrant to ensure it's only called once + const fetchGrantSpy = sinon.spy(testHarness.agent.permissions, 'fetchGrants'); + + // get the grant + const fetchedMessagesQueryGrant = await testHarness.agent.permissions.getPermissionForRequest({ + connectedDid : aliceDid.uri, + delegateDid : bobDid.uri, + messageType : DwnInterface.RecordsWrite, + protocol : protocolUri, + cached : true + }); + expect(fetchedMessagesQueryGrant.message.recordId).to.equal(recordsWriteGrant.message.recordId); + + expect(fetchGrantSpy.callCount).to.equal(1, 'fetched'); + + // get the grant again + const fetchedMessagesQueryGrant2 = await testHarness.agent.permissions.getPermissionForRequest({ + connectedDid : aliceDid.uri, + delegateDid : bobDid.uri, + messageType : DwnInterface.RecordsWrite, + protocol : protocolUri, + cached : true + }); + expect(fetchedMessagesQueryGrant2.message.recordId).to.equal(recordsWriteGrant.message.recordId); + + // expect the fetchGrant method to not have been called again + expect(fetchGrantSpy.callCount).to.equal(1, 'got from cache'); + }); + + it('should cache the results of a fetch even if cache is set to false', async () => { + // create a RecordsWrite grant from alice to bob + const protocolUri = 'http://example.com/protocol'; + const recordsWriteGrant = await testHarness.agent.permissions.createGrant({ + store : true, + author : aliceDid.uri, + grantedTo : bobDid.uri, + dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), + scope : { + interface : DwnInterfaceName.Records, + method : DwnMethodName.Write, + protocol : protocolUri + } + }); + expect(recordsWriteGrant).to.exist; + + // store as bob + const { encodedData, ...recordsWriteGrantMessage } = recordsWriteGrant.message; + const grantReply = await testHarness.agent.processDwnRequest({ + target : bobDid.uri, + author : bobDid.uri, + signAsOwner : true, + messageType : DwnInterface.RecordsWrite, + rawMessage : recordsWriteGrantMessage, + dataStream : new Blob([ Convert.base64Url(encodedData).toUint8Array() ]) + }); + expect(grantReply.reply.status.code).to.equal(202); + + // spy on fetchGrant to ensure it's only called once + const fetchGrantSpy = sinon.spy(testHarness.agent.permissions, 'fetchGrants'); + + // get the grant with cache set to false (default) + // this will refresh the cache with the result anyway, but will always call fetchGrant when set to false + const fetchedMessagesQueryGrant = await testHarness.agent.permissions.getPermissionForRequest({ + connectedDid : aliceDid.uri, + delegateDid : bobDid.uri, + messageType : DwnInterface.RecordsWrite, + protocol : protocolUri, + cached : false + }); + expect(fetchedMessagesQueryGrant.message.recordId).to.equal(recordsWriteGrant.message.recordId); + + expect(fetchGrantSpy.callCount).to.equal(1, 'fetched'); + + // get the grant again (with cache set to true) + const fetchedMessagesQueryGrant2 = await testHarness.agent.permissions.getPermissionForRequest({ + connectedDid : aliceDid.uri, + delegateDid : bobDid.uri, + messageType : DwnInterface.RecordsWrite, + protocol : protocolUri, + cached : true + }); + expect(fetchedMessagesQueryGrant2.message.recordId).to.equal(recordsWriteGrant.message.recordId); + + // expect the fetchGrant method to not have been called again + expect(fetchGrantSpy.callCount).to.equal(1, 'got from cache'); + + // call again with cache set to false + const fetchedMessagesQueryGrant3 = await testHarness.agent.permissions.getPermissionForRequest({ + connectedDid : aliceDid.uri, + delegateDid : bobDid.uri, + messageType : DwnInterface.RecordsWrite, + protocol : protocolUri, + cached : false + }); + expect(fetchedMessagesQueryGrant3.message.recordId).to.equal(recordsWriteGrant.message.recordId); + + // now cache was not set to true, so expect the fetchGrant method to have been called again + expect(fetchGrantSpy.callCount).to.equal(2, 'fetched again'); + }); + }); + describe('fetchGrants', () => { it('from remote', async () => { // spy on the processDwnRequest method diff --git a/packages/agent/tests/sync-engine-level.spec.ts b/packages/agent/tests/sync-engine-level.spec.ts index 85003f6ec..5037f6a30 100644 --- a/packages/agent/tests/sync-engine-level.spec.ts +++ b/packages/agent/tests/sync-engine-level.spec.ts @@ -159,6 +159,7 @@ describe('SyncEngineLevel', () => { await testHarness.dwnEventLog.clear(); await testHarness.dwnMessageStore.clear(); await testHarness.dwnResumableTaskStore.clear(); + await testHarness.agent.permissions.clear(); testHarness.dwnStores.clear(); }); diff --git a/packages/api/src/dwn-api.ts b/packages/api/src/dwn-api.ts index 556225592..ab695e9a8 100644 --- a/packages/api/src/dwn-api.ts +++ b/packages/api/src/dwn-api.ts @@ -7,6 +7,7 @@ import type { CreateGrantParams, CreateRequestParams, + DwnRecordsInterfaces, FetchPermissionRequestParams, FetchPermissionsParams } from '@web5/agent'; @@ -18,7 +19,6 @@ import { DwnMessageParams, DwnMessageSubscription, DwnResponseStatus, - CachedPermissions, ProcessDwnRequest, DwnPaginationCursor, AgentPermissionsApi, @@ -220,6 +220,9 @@ export type RecordsSubscribeRequest = { /** Optional DID specifying the remote target DWN tenant to subscribe from. */ from?: string; + /** Records must be scoped to a specific protocol */ + protocol?: string; + /** The parameters for the subscription operation, detailing the criteria for the subscription filter */ message: Omit; @@ -301,54 +304,11 @@ export class DwnApi { /** Holds the instance of {@link AgentPermissionsApi} that helps when dealing with permissions protocol records */ private permissionsApi: AgentPermissionsApi; - /** cache for fetching a permission {@link PermissionGrant}, keyed by a specific MessageType and protocol */ - private cachedPermissionsApi: CachedPermissions; - constructor(options: { agent: Web5Agent, connectedDid: string, delegateDid?: string }) { this.agent = options.agent; this.connectedDid = options.connectedDid; this.delegateDid = options.delegateDid; this.permissionsApi = new AgentPermissionsApi({ agent: this.agent }); - this.cachedPermissionsApi = new CachedPermissions({ agent: this.agent, cachedDefault: true }); - } - - /** - * API to interact with the DWN Permissions when the agent is connected to a delegateDid. - * - * NOTE: This is an EXPERIMENTAL API that will change behavior. - * @beta - */ - private get connected() { - return { - /** - * Finds the appropriate permission grants associated with a message request - * - * (optionally) Caches the results for the given parameters to avoid redundant queries. - */ - findPermissionGrantForMessage: async ({ messageParams, cached = true }:{ - cached?: boolean; - messageParams: { - messageType: T; - protocol: string; - } - }) : Promise => { - if(!this.delegateDid) { - throw new Error('AgentDwnApi: Cannot find connected grants without a signer DID'); - } - - const delegateGrant = await this.cachedPermissionsApi.getPermission({ - connectedDid : this.connectedDid, - delegateDid : this.delegateDid, - messageType : messageParams.messageType, - protocol : messageParams.protocol, - delegate : true, - cached, - }); - - const grant = await PermissionGrant.parse({ connectedDid: this.delegateDid, agent: this.agent, message: delegateGrant.message }); - return grant; - } - }; } /** @@ -522,6 +482,7 @@ export class DwnApi { * API to interact with DWN records (e.g., `dwn.records.create()`). */ get records() { + return { /** * Alias for the `write` method @@ -585,16 +546,19 @@ export class DwnApi { }; if (this.delegateDid) { - // if an app is scoped down to a specific protocolPath or contextId, it must include those filters in the read request - const { rawMessage: delegatedGrant } = await this.connected.findPermissionGrantForMessage({ - messageParams: { - messageType : DwnInterface.RecordsDelete, - protocol : request.protocol, - } + const { message: delegatedGrant } = await this.permissionsApi.getPermissionForRequest({ + connectedDid : this.connectedDid, + delegateDid : this.delegateDid, + protocol : request.protocol, + delegate : true, + cached : true, + messageType : agentRequest.messageType }); - // set the required delegated grant and grantee DID for the read operation - agentRequest.messageParams.delegatedGrant = delegatedGrant; + agentRequest.messageParams = { + ...agentRequest.messageParams, + delegatedGrant + }; agentRequest.granteeDid = this.delegateDid; } @@ -631,16 +595,19 @@ export class DwnApi { }; if (this.delegateDid) { - // if an app is scoped down to a specific protocolPath or contextId, it must include those filters in the read request - const { rawMessage: delegatedGrant } = await this.connected.findPermissionGrantForMessage({ - messageParams: { - messageType : DwnInterface.RecordsQuery, - protocol : agentRequest.messageParams.filter.protocol, - } + const { message: delegatedGrant } = await this.permissionsApi.getPermissionForRequest({ + connectedDid : this.connectedDid, + delegateDid : this.delegateDid, + protocol : request.protocol, + delegate : true, + cached : true, + messageType : agentRequest.messageType }); - // set the required delegated grant and grantee DID for the read operation - agentRequest.messageParams.delegatedGrant = delegatedGrant; + agentRequest.messageParams = { + ...agentRequest.messageParams, + delegatedGrant + }; agentRequest.granteeDid = this.delegateDid; } @@ -654,7 +621,7 @@ export class DwnApi { } const reply = agentResponse.reply; - const { entries, status, cursor } = reply; + const { entries = [], status, cursor } = reply; const records = entries.map((entry) => { @@ -677,9 +644,10 @@ export class DwnApi { * payload exceeds the threshold for being returned with queries. */ remoteOrigin : request.from, + delegateDid : this.delegateDid, ...entry as DwnMessage[DwnInterface.RecordsWrite] }; - const record = new Record(this.agent, recordOptions); + const record = new Record(this.agent, recordOptions, this.permissionsApi); return record; }); @@ -705,18 +673,20 @@ export class DwnApi { */ target : request.from || this.connectedDid }; - if (this.delegateDid) { - // if an app is scoped down to a specific protocolPath or contextId, it must include those filters in the read request - const { rawMessage: delegatedGrant } = await this.connected.findPermissionGrantForMessage({ - messageParams: { - messageType : DwnInterface.RecordsRead, - protocol : request.protocol - } + const { message: delegatedGrant } = await this.permissionsApi.getPermissionForRequest({ + connectedDid : this.connectedDid, + delegateDid : this.delegateDid, + protocol : request.protocol, + delegate : true, + cached : true, + messageType : agentRequest.messageType }); - // set the required delegated grant and grantee DID for the read operation - agentRequest.messageParams.delegatedGrant = delegatedGrant; + agentRequest.messageParams = { + ...agentRequest.messageParams, + delegatedGrant + }; agentRequest.granteeDid = this.delegateDid; } @@ -751,10 +721,11 @@ export class DwnApi { * payload must be read again (e.g., if the data stream is consumed). */ remoteOrigin : request.from, + delegateDid : this.delegateDid, ...responseRecord, }; - record = new Record(this.agent, recordOptions); + record = new Record(this.agent, recordOptions, this.permissionsApi); } return { record, status }; @@ -786,12 +757,31 @@ export class DwnApi { * The handler to process the subscription events. */ subscriptionHandler: SubscriptionUtil.recordSubscriptionHandler({ - agent : this.agent, - connectedDid : this.connectedDid, + agent : this.agent, + connectedDid : this.connectedDid, + delegateDid : this.delegateDid, + permissionsApi : this.permissionsApi, request }) }; + if (this.delegateDid) { + const { message: delegatedGrant } = await this.permissionsApi.getPermissionForRequest({ + connectedDid : this.connectedDid, + delegateDid : this.delegateDid, + protocol : request.protocol, + delegate : true, + cached : true, + messageType : agentRequest.messageType + }); + + agentRequest.messageParams = { + ...agentRequest.messageParams, + delegatedGrant + }; + agentRequest.granteeDid = this.delegateDid; + }; + let agentResponse: DwnResponse; if (request.from) { @@ -832,15 +822,19 @@ export class DwnApi { // if impersonation is enabled, fetch the delegated grant to use with the write operation if (this.delegateDid) { - const { rawMessage: delegatedGrant } = await this.connected.findPermissionGrantForMessage({ - messageParams: { - messageType : DwnInterface.RecordsWrite, - protocol : dwnRequestParams.messageParams.protocol, - } + const { message: delegatedGrant } = await this.permissionsApi.getPermissionForRequest({ + connectedDid : this.connectedDid, + delegateDid : this.delegateDid, + protocol : request.message.protocol, + delegate : true, + cached : true, + messageType : dwnRequestParams.messageType }); - // set the required delegated grant and grantee DID for the write operation - dwnRequestParams.messageParams.delegatedGrant = delegatedGrant; + dwnRequestParams.messageParams = { + ...dwnRequestParams.messageParams, + delegatedGrant + }; dwnRequestParams.granteeDid = this.delegateDid; }; @@ -863,10 +857,11 @@ export class DwnApi { */ connectedDid : this.connectedDid, encodedData : dataBlob, + delegateDid : this.delegateDid, ...responseMessage, }; - record = new Record(this.agent, recordOptions); + record = new Record(this.agent, recordOptions, this.permissionsApi); } return { record, status }; diff --git a/packages/api/src/record.ts b/packages/api/src/record.ts index 3c8f045ff..0e185eaaf 100644 --- a/packages/api/src/record.ts +++ b/packages/api/src/record.ts @@ -19,12 +19,14 @@ import { DwnPaginationCursor, isDwnMessage, SendDwnRequest, - isRecordsWrite + PermissionsApi, + AgentPermissionsApi, } from '@web5/agent'; import { Convert, isEmptyObject, NodeStream, removeUndefinedProperties, Stream } from '@web5/common'; import { dataToBlob, SendCache } from './utils.js'; +import { PermissionGrant } from './permission-grant.js'; /** * Represents Immutable Record properties that cannot be changed after the record is created. @@ -92,6 +94,9 @@ export type RecordOptions = DwnMessage[DwnInterface.RecordsWrite | DwnInterface. /** The DID of the DWN tenant under which record operations are being performed. */ connectedDid: string; + /** The optional DID that will sign the records on behalf of the connectedDid */ + delegateDid?: string; + /** The data of the record, either as a Base64 URL encoded string or a Blob. */ encodedData?: string | Blob; @@ -205,6 +210,10 @@ export class Record implements RecordModel { private _agent: Web5Agent; /** The DID of the DWN tenant under which operations are being performed. */ private _connectedDid: string; + /** The optional DID that is delegated to act on behalf of the connectedDid */ + private _delegateDid?: string; + /** cache for fetching a permission {@link PermissionGrant}, keyed by a specific MessageType and protocol */ + private _permissionsApi: PermissionsApi; /** Encoded data of the record, if available. */ private _encodedData?: Blob; /** Stream of the record's data. */ @@ -255,8 +264,8 @@ export class Record implements RecordModel { /** Record's ID */ get id() { return this._recordId; } - /** Record's context ID */ - get contextId() { return this._contextId; } + /** Record's context ID. If the record is deleted, the context Id comes from the initial write */ + get contextId() { return this.deleted ? this._initialWrite.contextId : this._contextId; } /** Record's creation date */ get dateCreated() { return this._immutableProperties.dateCreated; } @@ -348,7 +357,7 @@ export class Record implements RecordModel { return message; } - constructor(agent: Web5Agent, options: RecordOptions) { + constructor(agent: Web5Agent, options: RecordOptions, permissionsApi?: PermissionsApi) { this._agent = agent; @@ -356,9 +365,11 @@ export class Record implements RecordModel { // that they don't have to decode the signer's DID from the JWS. this._author = options.author; - // Store the currently `connectedDid` so that subsequent message signing is done with the - // connected DID's keys and DWN requests target the connected DID's DWN. + // Store the `connectedDid`, and optionally the `delegateDid` and `permissionsApi` in order to be able + // to perform operations on the record (update, delete, data) as a delegate of the connected DID. this._connectedDid = options.connectedDid; + this._delegateDid = options.delegateDid; + this._permissionsApi = permissionsApi ?? new AgentPermissionsApi({ agent }); // If the record was queried or read from a remote DWN, the `remoteOrigin` DID will be // defined. This value is used to send subsequent read requests to the same remote DWN in the @@ -683,8 +694,8 @@ export class Record implements RecordModel { */ async update({ dateModified, data, ...params }: RecordUpdateParams): Promise { - if (!isDwnMessage(DwnInterface.RecordsWrite, this.rawMessage) && !this._initialWrite) { - throw new Error('If initial write is not set, the current rawRecord must be a RecordsWrite message.'); + if (this.deleted) { + throw new Error('Record: Cannot revive a deleted record.'); } // if there is a parentId, we remove it from the descriptor and set a parentContextId @@ -728,13 +739,28 @@ export class Record implements RecordModel { delete updateMessage.datePublished; } - const agentResponse = await this._agent.processDwnRequest({ + const requestOptions: ProcessDwnRequest = { author : this._connectedDid, dataStream : dataBlob, messageParams : { ...updateMessage }, messageType : DwnInterface.RecordsWrite, target : this._connectedDid, - }); + }; + + if (this._delegateDid) { + const { message: delegatedGrant } = await this._permissionsApi.getPermissionForRequest({ + connectedDid : this._connectedDid, + delegateDid : this._delegateDid, + protocol : this.protocol, + delegate : true, + cached : true, + messageType : requestOptions.messageType + }); + requestOptions.messageParams.delegatedGrant = delegatedGrant; + requestOptions.granteeDid = this._delegateDid; + } + + const agentResponse = await this._agent.processDwnRequest(requestOptions); const { message, reply: { status } } = agentResponse; const responseMessage = message; @@ -769,35 +795,64 @@ export class Record implements RecordModel { * @returns the status of the delete request */ async delete(deleteParams?: RecordDeleteParams): Promise { - const { store, signAsOwner, dateModified, ...params } = deleteParams || {}; + const { store = true, signAsOwner, dateModified, prune = false } = deleteParams || {}; + + const signAsOwnerValue = signAsOwner && this._delegateDid === undefined; + const signAsOwnerDelegate = signAsOwner && this._delegateDid !== undefined; + + if (this.deleted && !this._initialWrite) { + throw new Error('Record: Record is in an invalid state, initial write is missing.'); + } + + if (!this._initialWrite) { + // If there is no initial write, we need to create one from the current record state. + // We checked in the beginning of the function that the initialWrite is not set if the rawMessage is a RecordsDelete message. + // So we can safely assume that the rawMessage is a RecordsWrite message. + this._initialWrite = { ...this.rawMessage as DwnMessage[DwnInterface.RecordsWrite] }; + } + + await this.processInitialWriteIfNeeded({ store, signAsOwner }); // prepare delete options let deleteOptions: ProcessDwnRequest = { messageType : DwnInterface.RecordsDelete, author : this._connectedDid, target : this._connectedDid, - store, - signAsOwner + signAsOwner : signAsOwnerValue, + signAsOwnerDelegate, + store }; if (this.deleted) { - if (!this._initialWrite) { - // if the rawMessage is a `RecordsDelete` the initial message must be set. - // this should never happen, but we check as a form of defensive programming. - throw new Error('If initial write is not set, the current rawRecord must be a RecordsWrite message.'); - } - // if we have a delete message we can just use it deleteOptions.rawMessage = this.rawMessage as DwnMessage[DwnInterface.RecordsDelete]; } else { // otherwise we construct a delete message given the `RecordDeleteParams` deleteOptions.messageParams = { - ...params, + prune : prune, recordId : this._recordId, messageTimestamp : dateModified, }; } + if (this._delegateDid) { + const { message: delegatedGrant } = await this._permissionsApi.getPermissionForRequest({ + connectedDid : this._connectedDid, + delegateDid : this._delegateDid, + protocol : this.protocol, + delegate : true, + cached : true, + messageType : deleteOptions.messageType + }); + + deleteOptions.messageParams = { + ...deleteOptions.messageParams, + delegatedGrant + }; + + deleteOptions.granteeDid = this._delegateDid; + } + const agentResponse = await this._agent.processDwnRequest(deleteOptions); const { message, reply: { status } } = agentResponse; @@ -806,13 +861,6 @@ export class Record implements RecordModel { return { status }; } - if (!this._initialWrite) { - // If there is no initial write, we need to create one from the current record state. - // We checked in the beginning of the function that the initialWrite is not set if the rawMessage is a RecordsDelete message. - // So we can safely assume that the rawMessage is a RecordsWrite message. - this._initialWrite = { ...this.rawMessage as DwnMessage[DwnInterface.RecordsWrite] }; - } - // If the delete was successful, update the Record author to the author of the delete message. this._author = getRecordAuthor(message); this._descriptor = message.descriptor; @@ -822,34 +870,53 @@ export class Record implements RecordModel { this._encodedData = undefined; this._encryption = undefined; this._attestation = undefined; + this._contextId = undefined; return { status }; } /** - * Handles the various conditions around there being an initial write, whether to store initial/current state, - * and whether to add an owner signature to the initial write to enable storage when protocol rules require it. + * Process the initial write, if it hasn't already been processed, with the options set for storing and/or signing as the owner. */ - private async processRecord({ store, signAsOwner }:{ store: boolean, signAsOwner: boolean }): Promise { - // if there is an initial write and we haven't already processed it, we first process it and marked it as such. - if (this._initialWrite && ((signAsOwner && !this._initialWriteSigned) || (store && !this._initialWriteStored))) { + private async processInitialWriteIfNeeded({ store, signAsOwner }:{ store: boolean, signAsOwner: boolean }): Promise { + if (this.initialWrite && ((signAsOwner && !this._initialWriteSigned) || (store && !this._initialWriteStored))) { + const signAsOwnerValue = signAsOwner && this._delegateDid === undefined; + const signAsOwnerDelegate = signAsOwner && this._delegateDid !== undefined; + const initialWriteRequest: ProcessDwnRequest = { messageType : DwnInterface.RecordsWrite, rawMessage : this.initialWrite, author : this._connectedDid, target : this._connectedDid, - signAsOwner, + signAsOwner : signAsOwnerValue, + signAsOwnerDelegate, store, }; + if (this._delegateDid) { + const { message: delegatedGrant } = await this._permissionsApi.getPermissionForRequest({ + connectedDid : this._connectedDid, + delegateDid : this._delegateDid, + protocol : this.protocol, + delegate : true, + cached : true, + messageType : initialWriteRequest.messageType + }); + + initialWriteRequest.messageParams = { + ...initialWriteRequest.messageParams, + delegatedGrant + }; + + initialWriteRequest.granteeDid = this._delegateDid; + } + // Process the prepared initial write, with the options set for storing and/or signing as the owner. const agentResponse = await this._agent.processDwnRequest(initialWriteRequest); const { message, reply: { status } } = agentResponse; const responseMessage = message; - // If we are signing as owner, make sure to update the initial write's authorization, because now it will have the owner's signature on it - // set the stored or signed status to true so we don't process it again. if (200 <= status.code && status.code <= 299) { if (store) this._initialWriteStored = true; if (signAsOwner) { @@ -858,6 +925,17 @@ export class Record implements RecordModel { } } } + } + + /** + * Handles the various conditions around there being an initial write, whether to store initial/current state, + * and whether to add an owner signature to the initial write to enable storage when protocol rules require it. + */ + private async processRecord({ store, signAsOwner }:{ store: boolean, signAsOwner: boolean }): Promise { + const signAsOwnerValue = signAsOwner && this._delegateDid === undefined; + const signAsOwnerDelegate = signAsOwner && this._delegateDid !== undefined; + + await this.processInitialWriteIfNeeded({ store, signAsOwner }); let requestOptions: ProcessDwnRequest; // Now that we've processed a potential initial write, we can process the current record state. @@ -868,7 +946,8 @@ export class Record implements RecordModel { rawMessage : this.rawMessage, author : this._connectedDid, target : this._connectedDid, - signAsOwner, + signAsOwner : signAsOwnerValue, + signAsOwnerDelegate, store, }; } else { @@ -878,11 +957,30 @@ export class Record implements RecordModel { author : this._connectedDid, target : this._connectedDid, dataStream : await this.data.blob(), - signAsOwner, + signAsOwner : signAsOwnerValue, + signAsOwnerDelegate, store, }; } + if (this._delegateDid) { + const { message: delegatedGrant } = await this._permissionsApi.getPermissionForRequest({ + connectedDid : this._connectedDid, + delegateDid : this._delegateDid, + protocol : this.protocol, + delegate : true, + cached : true, + messageType : requestOptions.messageType + }); + + requestOptions.messageParams = { + ...requestOptions.messageParams, + delegatedGrant + }; + + requestOptions.granteeDid = this._delegateDid; + } + const agentResponse = await this._agent.processDwnRequest(requestOptions); const { message, reply: { status } } = agentResponse; const responseMessage = message; @@ -919,6 +1017,24 @@ export class Record implements RecordModel { target, }; + if (this._delegateDid) { + const { message: delegatedGrant } = await this._permissionsApi.getPermissionForRequest({ + connectedDid : this._connectedDid, + delegateDid : this._delegateDid, + protocol : this.protocol, + delegate : true, + cached : true, + messageType : readRequest.messageType + }); + + readRequest.messageParams = { + ...readRequest.messageParams, + delegatedGrant + }; + + readRequest.granteeDid = this._delegateDid; + } + const agentResponsePromise = isRemote ? this._agent.sendDwnRequest(readRequest) : this._agent.processDwnRequest(readRequest); diff --git a/packages/api/src/subscription-util.ts b/packages/api/src/subscription-util.ts index 733647aee..5316733d0 100644 --- a/packages/api/src/subscription-util.ts +++ b/packages/api/src/subscription-util.ts @@ -1,4 +1,4 @@ -import { DwnRecordSubscriptionHandler, getRecordAuthor, Web5Agent } from '@web5/agent'; +import { DwnRecordSubscriptionHandler, getRecordAuthor, PermissionsApi, Web5Agent } from '@web5/agent'; import { RecordsSubscribeRequest } from './dwn-api.js'; import { Record } from './record.js'; @@ -9,9 +9,11 @@ export class SubscriptionUtil { /** * Creates a record subscription handler that can be used to process incoming {Record} messages. */ - static recordSubscriptionHandler({ agent, connectedDid, request }:{ + static recordSubscriptionHandler({ agent, connectedDid, request, delegateDid, permissionsApi }:{ agent: Web5Agent; connectedDid: string; + delegateDid?: string; + permissionsApi?: PermissionsApi; request: RecordsSubscribeRequest; }): DwnRecordSubscriptionHandler { const { subscriptionHandler, from: remoteOrigin } = request; @@ -26,7 +28,12 @@ export class SubscriptionUtil { initialWrite }; - const record = new Record(agent, { ...message, ...recordOptions }); + const record = new Record(agent, { + ...message, + ...recordOptions, + delegateDid: delegateDid, + }, permissionsApi); + subscriptionHandler(record); }; } diff --git a/packages/api/src/web5.ts b/packages/api/src/web5.ts index 6fc5d5d8f..de33ad20a 100644 --- a/packages/api/src/web5.ts +++ b/packages/api/src/web5.ts @@ -439,7 +439,6 @@ export class Web5 { // If we are using WalletConnect, we should do a one-shot sync to pull down any messages that are associated with the connectedDid await userAgent.sync.sync('pull'); } - } // Enable sync using the specified interval or default. diff --git a/packages/api/tests/did-api.spec.ts b/packages/api/tests/did-api.spec.ts index 7a03ce5a0..963325401 100644 --- a/packages/api/tests/did-api.spec.ts +++ b/packages/api/tests/did-api.spec.ts @@ -1,8 +1,10 @@ import { expect } from 'chai'; import { Web5UserAgent } from '@web5/user-agent'; import { PlatformAgentTestHarness } from '@web5/agent'; +import sinon from 'sinon'; import { DidApi } from '../src/did-api.js'; +import { DidDht } from '@web5/dids'; describe('DidApi', () => { let did: DidApi; @@ -16,6 +18,7 @@ describe('DidApi', () => { }); beforeEach(async () => { + sinon.restore(); await testHarness.clearStorage(); await testHarness.createAgentDid(); @@ -30,6 +33,7 @@ describe('DidApi', () => { }); after(async () => { + sinon.restore(); await testHarness.clearStorage(); await testHarness.closeStorage(); }); @@ -64,6 +68,22 @@ describe('DidApi', () => { describe('resolve()', () => { it('resolves a DID and returns a resolution result', async () => { + + // avoid actually resolving the DHT + sinon.stub(DidDht, 'resolve').resolves({ + didDocument: { + id : 'did:dht:ugkhixpk56o9izfp4ucc543scj5ajcis3rkh43yueq98qiaj8tgy', + '@context' : 'https://w3id.org/did/v1', + verificationMethod : [ + ], + authentication: [ + 'did:dht:ugkhixpk56o9izfp4ucc543scj5ajcis3rkh43yueq98qiaj8tgy#keys-1' + ] + }, + didDocumentMetadata : {}, + didResolutionMetadata : {} + }); + const testDid = 'did:dht:ugkhixpk56o9izfp4ucc543scj5ajcis3rkh43yueq98qiaj8tgy'; const didResolutionResult = await did.resolve(testDid); diff --git a/packages/api/tests/dwn-api.spec.ts b/packages/api/tests/dwn-api.spec.ts index cad98bab1..08663e149 100644 --- a/packages/api/tests/dwn-api.spec.ts +++ b/packages/api/tests/dwn-api.spec.ts @@ -3,17 +3,18 @@ import type { BearerDid } from '@web5/dids'; import sinon from 'sinon'; import { expect } from 'chai'; import { Web5UserAgent } from '@web5/user-agent'; -import { AgentPermissionsApi, DwnDateSort, DwnInterface, DwnProtocolDefinition, getRecordAuthor, PlatformAgentTestHarness } from '@web5/agent'; +import { AgentPermissionsApi, DwnDateSort, DwnProtocolDefinition, getRecordAuthor, Oidc, PlatformAgentTestHarness, 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, PermissionsProtocol, Poller, RecordsWrite, Time } from '@tbd54566975/dwn-sdk-js'; +import { DwnInterfaceName, DwnMethodName, Jws, PermissionsProtocol, Poller, Time } from '@tbd54566975/dwn-sdk-js'; import { PermissionGrant } from '../src/permission-grant.js'; -import { Web5 } from '../src/web5.js'; import { Record } from '../src/record.js'; import { TestDataGenerator } from './utils/test-data-generator.js'; +import { PortableDid } from '@web5/dids'; +import { Web5 } from '../src/web5.js'; let testDwnUrls: string[] = [testDwnUrl]; @@ -57,12 +58,9 @@ describe('DwnApi', () => { await testHarness.dwnEventLog.clear(); await testHarness.dwnMessageStore.clear(); await testHarness.dwnResumableTaskStore.clear(); + await testHarness.agent.permissions.clear(); testHarness.dwnStores.clear(); - // clear cached permissions between test runs - dwnAlice['cachedPermissionsApi'].clear(); - dwnBob['cachedPermissionsApi'].clear(); - dwnAlice['connectedDid'] = aliceDid.uri; dwnBob['connectedDid'] = bobDid.uri; @@ -70,7 +68,7 @@ describe('DwnApi', () => { delete dwnBob['delegateDid']; // give the protocol a random URI on each run - protocolUri = `http://example.com/protocol/${TestDataGenerator.randomString(10)}`; + protocolUri = `http://example.com/protocol/${TestDataGenerator.randomString(15)}`; protocolDefinition = { ...emailProtocolDefinition, protocol: protocolUri @@ -83,6 +81,226 @@ describe('DwnApi', () => { await testHarness.closeStorage(); }); + describe('as delegateDid', () => { + let delegateHarness: PlatformAgentTestHarness; + let delegateDid: PortableDid; + let delegateDwn: DwnApi; + let notesProtocol: DwnProtocolDefinition; + + before(async () => { + delegateHarness = await PlatformAgentTestHarness.setup({ + agentClass : Web5UserAgent, + agentStores : 'memory', + testDataLocation : '__TESTDATA__/delegateDid' + }); + + await delegateHarness.clearStorage(); + await delegateHarness.createAgentDid(); + }); + + beforeEach(async () => { + sinon.restore(); + await delegateHarness.syncStore.clear(); + await delegateHarness.dwnDataStore.clear(); + await delegateHarness.dwnEventLog.clear(); + await delegateHarness.dwnMessageStore.clear(); + await delegateHarness.dwnResumableTaskStore.clear(); + await testHarness.agent.permissions.clear(); + delegateHarness.dwnStores.clear(); + + // avoid seeing the security warning of no password during connect + sinon.stub(console, 'warn'); + + notesProtocol = { + published : true, + protocol : `http://notes-protocol.xyz/protocol/${TestDataGenerator.randomString(15)}`, + types : { + note: { + schema : 'https://notes-protocol.xyz/schema/note', + dataFormats : [ 'text/plain', 'application/json' ] + } + }, + structure: { + note: {} + } + }; + + // Create a "device" JWK to use as the delegateDid + const delegatedBearerDid = await testHarness.agent.did.create({ store: false, method: 'jwk', }); + delegateDid = await delegatedBearerDid.export(); + + const grantRequest = WalletConnect.createPermissionRequestForProtocol({ + definition : notesProtocol, + permissions : ['write', 'read', 'delete', 'query', 'subscribe'] + }); + + // alice and bob both configure the protocol + const { status: aliceConfigStatus, protocol: aliceNotesProtocol } = await dwnAlice.protocols.configure({ message: { definition: notesProtocol } }); + expect(aliceConfigStatus.code).to.equal(202); + const { status: aliceNotesProtocolSend } = await aliceNotesProtocol.send(aliceDid.uri); + expect(aliceNotesProtocolSend.code).to.equal(202); + + const { status: bobConfigStatus, protocol: bobNotesProtocol } = await dwnBob.protocols.configure({ message: { definition: notesProtocol } }); + expect(bobConfigStatus.code).to.equal(202); + const { status: bobNotesProtocolSend } = await bobNotesProtocol!.send(bobDid.uri); + expect(bobNotesProtocolSend.code).to.equal(202); + + const grants = await Oidc.createPermissionGrants(aliceDid.uri, delegatedBearerDid, testHarness.agent, grantRequest.permissionScopes); + + sinon.stub(Web5UserAgent, 'create').resolves(delegateHarness.agent as Web5UserAgent); + sinon.stub(WalletConnect, 'createPermissionRequestForProtocol').resolves(grantRequest); + sinon.stub(delegateHarness.agent.identity, 'connectedIdentity').resolves(undefined); + sinon.stub(delegateHarness.agent.sync, 'startSync').resolves(); + // // stub WalletConnect.initClient to return the did and grants + sinon.stub(WalletConnect, 'initClient').resolves({ + connectedDid : aliceDid.uri, + delegatePortableDid : delegateDid, + delegateGrants : grants, + }); + + // connect with grants + ({ web5: { dwn: delegateDwn } } = await Web5.connect({ walletConnectOptions: { + permissionRequests: [ grantRequest ] + } as any })); + }); + + it('should create a record with a delegated grant', async () => { + const { status, record } = await delegateDwn.records.create({ + data : 'Hello, world!', + message : { + protocol : notesProtocol.protocol, + protocolPath : 'note', + schema : notesProtocol.types.note.schema, + dataFormat : 'text/plain', + } + }); + + expect(status.code).to.equal(202); + expect(record).to.not.be.undefined; + + // alice is the author, but the signer is the delegateDid + expect(record.author).to.equal(aliceDid.uri); + const signerDid = Jws.getSignerDid(record.rawMessage.authorization.signature.signatures[0]); + expect(signerDid).to.equal(delegateDid.uri); + expect(record.rawMessage.authorization.authorDelegatedGrant).to.not.be.undefined; + }); + + it('should query records with a delegated grant', async () => { + const { status: writeStatus, record } = await delegateDwn.records.create({ + data : 'Hello, world!', + message : { + protocol : notesProtocol.protocol, + protocolPath : 'note', + schema : notesProtocol.types.note.schema, + dataFormat : 'text/plain', + } + }); + + expect(writeStatus.code).to.equal(202); + expect(record).to.not.be.undefined; + + const { status: queryStatus, records } = await delegateDwn.records.query({ + protocol : notesProtocol.protocol, + message : { + filter: { + protocol : notesProtocol.protocol, + protocolPath : 'note' + } + } + }); + + expect(queryStatus.code).to.equal(200); + expect(records).to.exist; + expect(records).to.have.lengthOf(1); + + // alice is the author, but the signer is the delegateDid + expect(records![0].author).to.equal(aliceDid.uri); + const signerDid = Jws.getSignerDid(records![0].rawMessage.authorization.signature.signatures[0]); + expect(signerDid).to.equal(delegateDid.uri); + expect(records![0].rawMessage.authorization.authorDelegatedGrant).to.not.be.undefined; + + // the record should be the same + expect(records![0].id).to.equal(record!.id); + }); + + it('should subscribe to records with a delegated grant', async () => { + // subscribe to all messages from the protocol + const records: Map = new Map(); + const subscriptionHandler = async (record: Record) => { + records.set(record.id, record); + }; + + const subscribeResult = await delegateDwn.records.subscribe({ + protocol : notesProtocol.protocol, + message : { + filter: { + protocol: notesProtocol.protocol + } + }, + subscriptionHandler + }); + expect(subscribeResult.status.code).to.equal(200); + + // write a record + const writeResult = await delegateDwn.records.write({ + data : 'Hello, world!', + message : { + recipient : bobDid.uri, + protocol : notesProtocol.protocol, + protocolPath : 'note', + schema : notesProtocol.types.note.schema, + dataFormat : 'text/plain', + } + }); + expect(writeResult.status.code).to.equal(202); + + // wait for the record to be received + await Poller.pollUntilSuccessOrTimeout(async () => { + expect(records.size).to.equal(1); + const record = records.get(writeResult.record.id); + expect(record.toJSON()).to.deep.equal(writeResult.record.toJSON()); + expect(record.deleted).to.be.false; + }); + + // delete the record using the original writeResult instance of it + const deleteResult = await writeResult.record.delete(); + expect(deleteResult.status.code).to.equal(202); + + // wait for the record state to be reflected as deleted + await Poller.pollUntilSuccessOrTimeout(async () => { + const record = records.get(writeResult.record.id); + expect(record).to.exist; + expect(record.deleted).to.be.true; + }); + + // write another record and delete the previous one, the state should be updated + const writeResult2 = await delegateDwn.records.write({ + data : 'Hello, world!', + message : { + recipient : bobDid.uri, + protocol : notesProtocol.protocol, + protocolPath : 'note', + schema : notesProtocol.types.note.schema, + dataFormat : 'text/plain', + } + }); + expect(writeResult2.status.code).to.equal(202); + + // wait for the record to be received + await Poller.pollUntilSuccessOrTimeout(async () => { + expect(records.size).to.equal(2); + const record = records.get(writeResult2.record.id); + expect(record.toJSON()).to.deep.equal(writeResult2.record.toJSON()); + expect(record.deleted).to.be.false; + + //check the deleted record + const deletedRecord = records.get(writeResult.record.id); + expect(deletedRecord).to.exist; + expect(deletedRecord.deleted).to.be.true; + }); + }); + }); + describe('protocols.configure()', () => { describe('agent', () => { it('writes a protocol definition', async () => { @@ -613,13 +831,28 @@ describe('DwnApi', () => { }); describe('records.delete()', () => { + beforeEach(async() => { + // Configure the protocol on both DWNs + const { status: aliceProtocolStatus, protocol: aliceProtocol } = await dwnAlice.protocols.configure({ message: { definition: protocolDefinition } }); + expect(aliceProtocolStatus.code).to.equal(202); + expect(aliceProtocol).to.exist; + const { status: aliceProtocolSendStatus } = await aliceProtocol.send(aliceDid.uri); + expect(aliceProtocolSendStatus.code).to.equal(202); + const { status: bobProtocolStatus, protocol: bobProtocol } = await dwnBob.protocols.configure({ message: { definition: protocolDefinition } }); + expect(bobProtocolStatus.code).to.equal(202); + expect(bobProtocol).to.exist; + const { status: bobProtocolSendStatus } = await bobProtocol!.send(bobDid.uri); + expect(bobProtocolSendStatus.code).to.equal(202); + }); + describe('agent', () => { it('deletes a record', async () => { const { status: writeStatus, record } = await dwnAlice.records.write({ data : 'Hello, world!', message : { - schema : 'foo/bar', - dataFormat : 'text/plain' + protocol : protocolUri, + protocolPath : 'thread', + schema : protocolDefinition.types.thread.schema, } }); @@ -631,7 +864,8 @@ describe('DwnApi', () => { expect(status.code).to.equal(202); const deleteResult = await dwnAlice.records.delete({ - message: { + protocol : protocolUri, + message : { recordId: record!.id } }); @@ -730,12 +964,39 @@ describe('DwnApi', () => { it('returns a 404 when the specified record does not exist', async () => { let deleteResult = await dwnAlice.records.delete({ - message: { + protocol : protocolUri, + message : { recordId: 'abcd1234' } }); expect(deleteResult.status.code).to.equal(404); }); + + it('stores a deleted record along with its initialWrite', async () => { + // Write a record but do not store it + const { status: initialWriteStatus, record: initialWriteRecord } = await dwnAlice.records.write({ + store : false, + data : 'Hello, world!', + message : { + protocol : protocolUri, + protocolPath : 'thread', + schema : protocolDefinition.types.thread.schema, + } + }); + expect(initialWriteStatus.code).to.equal(202); + + // Delete the record without storing it + const { status: deleteStatus } = await initialWriteRecord.delete({ store: false }); + expect(deleteStatus.code).to.equal(202); + + // delete the record storing it + const { status: deleteStoreStatus } = await initialWriteRecord.delete(); + expect(deleteStoreStatus.code).to.equal(202); + + // try deleting it again + const { status: deleteStatus2 } = await initialWriteRecord.delete(); + expect(deleteStatus2.code).to.equal(404); + }); }); describe('from: did', () => { @@ -1575,6 +1836,11 @@ describe('DwnApi', () => { const record = records.get(writeResult2.record.id); expect(record.toJSON()).to.deep.equal(writeResult2.record.toJSON()); expect(record.deleted).to.be.false; + + //check the deleted record + const deletedRecord = records.get(writeResult.record.id); + expect(deletedRecord).to.exist; + expect(deletedRecord.deleted).to.be.true; }); }); }); @@ -1583,12 +1849,20 @@ describe('DwnApi', () => { it('subscribes to records from remote', async () => { // configure a protocol const protocolConfigure = await dwnAlice.protocols.configure({ - message: { definition: { ...emailProtocolDefinition, published: true } } + message: { definition: { ...protocolDefinition, published: true } } }); expect(protocolConfigure.status.code).to.equal(202); const protocolSend = await protocolConfigure.protocol.send(aliceDid.uri); expect(protocolSend.status.code).to.equal(202); + //configure the protocol on bob's DWN + const protocolConfigureBob = await dwnBob.protocols.configure({ + message: { definition: { ...protocolDefinition, published: true } } + }); + expect(protocolConfigureBob.status.code).to.equal(202); + const protocolSendBob = await protocolConfigureBob.protocol.send(bobDid.uri); + expect(protocolSendBob.status.code).to.equal(202); + // subscribe to all messages from the protocol const records: Map = new Map(); const subscriptionHandler = async (record: Record) => { @@ -1599,7 +1873,7 @@ describe('DwnApi', () => { from : aliceDid.uri, message : { filter: { - protocol: emailProtocolDefinition.protocol + protocol: protocolUri, } }, subscriptionHandler @@ -1611,9 +1885,9 @@ describe('DwnApi', () => { data : 'Hello, world!', message : { recipient : bobDid.uri, - protocol : emailProtocolDefinition.protocol, + protocol : protocolUri, protocolPath : 'thread', - schema : emailProtocolDefinition.types.thread.schema, + schema : protocolDefinition.types.thread.schema, dataFormat : 'text/plain' } }); @@ -1647,9 +1921,9 @@ describe('DwnApi', () => { data : 'Hello, world!', message : { recipient : bobDid.uri, - protocol : emailProtocolDefinition.protocol, + protocol : protocolUri, protocolPath : 'thread', - schema : emailProtocolDefinition.types.thread.schema, + schema : protocolDefinition.types.thread.schema, dataFormat : 'text/plain' } }); @@ -1667,126 +1941,6 @@ describe('DwnApi', () => { }); }); - describe('connected.findPermissionGrantForRequest', () => { - afterEach(() => { - - }); - - it('caches result', async () => { - // create a grant for bob - const deviceXGrant = await dwnAlice.permissions.grant({ - store : true, - grantedTo : bobDid.uri, - dateExpires : Time.createOffsetTimestamp({ seconds: 60 }), - delegated : true, - scope : { - interface : DwnInterfaceName.Records, - method : DwnMethodName.Write, - protocol : 'http://example.com/protocol' - } - }); - - // simulate a connect where bobDid can impersonate aliceDid - dwnBob['connectedDid'] = aliceDid.uri; - dwnBob['delegateDid'] = bobDid.uri; - - await Web5.processConnectedGrants({ - agent : testHarness.agent, - delegateDid : bobDid.uri, - grants : [ deviceXGrant.rawMessage ] - }); - - const fetchGrantsSpy = sinon.spy(AgentPermissionsApi.prototype, 'fetchGrants'); - - // find the grant for a request - let grantForRequest = await dwnBob['connected'].findPermissionGrantForMessage({ - messageParams: { - messageType : DwnInterface.RecordsWrite, - protocol : 'http://example.com/protocol' - } - }); - - // expect to have the grant - expect(grantForRequest).to.exist; - expect(grantForRequest.id).to.equal(deviceXGrant.id); - expect(fetchGrantsSpy.callCount).to.equal(1); - - fetchGrantsSpy.resetHistory(); - - // attempt to find the grant again - grantForRequest = await dwnBob['connected'].findPermissionGrantForMessage({ - messageParams: { - messageType : DwnInterface.RecordsWrite, - protocol : 'http://example.com/protocol' - } - }); - expect(grantForRequest).to.exist; - expect(grantForRequest.id).to.equal(deviceXGrant.id); - expect(fetchGrantsSpy.callCount).to.equal(0); - - // should call again if cached:false is passed - grantForRequest = await dwnBob['connected'].findPermissionGrantForMessage({ - messageParams: { - messageType : DwnInterface.RecordsWrite, - protocol : 'http://example.com/protocol' - }, - cached: false - }); - expect(grantForRequest).to.exist; - expect(grantForRequest.id).to.equal(deviceXGrant.id); - expect(fetchGrantsSpy.callCount).to.equal(1); - - // reset the spy - fetchGrantsSpy.resetHistory(); - expect(fetchGrantsSpy.callCount).to.equal(0); - - // call for a different grant - try { - await dwnBob['connected'].findPermissionGrantForMessage({ - messageParams: { - messageType : DwnInterface.RecordsRead, - protocol : 'http://example.com/protocol' - } - }); - expect.fail('Should have thrown an error'); - } catch(error:any) { - expect(error.message).to.equal('CachedPermissions: No permissions found for RecordsRead: http://example.com/protocol'); - } - expect(fetchGrantsSpy.callCount).to.equal(1); - - // call again to ensure grants which are not found are not cached - try { - await dwnBob['connected'].findPermissionGrantForMessage({ - messageParams: { - messageType : DwnInterface.RecordsRead, - protocol : 'http://example.com/protocol' - } - }); - expect.fail('Should have thrown an error'); - } catch(error:any) { - expect(error.message).to.equal('CachedPermissions: No permissions found for RecordsRead: http://example.com/protocol'); - } - - expect(fetchGrantsSpy.callCount).to.equal(2); // should have been called again - }); - - it('throws if no delegateDid is set', async () => { - // make sure delegateDid is undefined - dwnAlice['delegateDid'] = undefined; - try { - await dwnAlice['connected'].findPermissionGrantForMessage({ - messageParams: { - messageType : DwnInterface.RecordsWrite, - protocol : 'http://example.com/protocol' - } - }); - expect.fail('Error was not thrown'); - } catch (e) { - expect(e.message).to.equal('AgentDwnApi: Cannot find connected grants without a signer DID'); - } - }); - }); - describe('permissions.grant', () => { it('uses the connected DID to create a grant if no delegate DID is set', async () => { // scenario: create a permission grant for bob, confirm that alice is the signer @@ -2278,12 +2432,15 @@ describe('DwnApi', () => { scope : { interface : DwnInterfaceName.Records, method : DwnMethodName.Write, - protocol : 'http://example.com/protocol' + protocol : protocolUri, } }); // alice queries the remote DWN, should not find any grants - let fetchedGrants = await dwnAlice.permissions.queryGrants(); + let fetchedGrants = await dwnAlice.permissions.queryGrants({ + from : aliceDid.uri, + protocol : protocolUri + }); expect(fetchedGrants.length).to.equal(0); // send the grant to alice's remote DWN @@ -2292,7 +2449,8 @@ describe('DwnApi', () => { // alice queries the remote DWN for the grants fetchedGrants = await dwnAlice.permissions.queryGrants({ - from: aliceDid.uri + from : aliceDid.uri, + protocol : protocolUri }); expect(fetchedGrants.length).to.equal(1); expect(fetchedGrants[0].id).to.equal(bobGrant.id); @@ -2307,7 +2465,7 @@ describe('DwnApi', () => { scope : { interface : DwnInterfaceName.Records, method : DwnMethodName.Write, - protocol : 'http://example.com/protocol-1' + protocol : protocolUri + '-1' // protocol 1 } }); @@ -2318,20 +2476,20 @@ describe('DwnApi', () => { scope : { interface : DwnInterfaceName.Records, method : DwnMethodName.Write, - protocol : 'http://example.com/protocol-2' + protocol : protocolUri + '-2' // protocol 2 } }); // query for the grants with protocol-1 let fetchedGrants = await dwnAlice.permissions.queryGrants({ - protocol: 'http://example.com/protocol-1' + protocol: protocolUri + '-1' // protocol 1 }); expect(fetchedGrants.length).to.equal(1); expect(fetchedGrants[0].id).to.equal(bobGrant1.id); // query for the grants with protocol-2 fetchedGrants = await dwnAlice.permissions.queryGrants({ - protocol: 'http://example.com/protocol-2' + protocol: protocolUri + '-2' // protocol 2 }); expect(fetchedGrants.length).to.equal(1); expect(fetchedGrants[0].id).to.equal(bobGrant2.id); diff --git a/packages/api/tests/permission-grant.spec.ts b/packages/api/tests/permission-grant.spec.ts index 0ee7de3d1..cb070723b 100644 --- a/packages/api/tests/permission-grant.spec.ts +++ b/packages/api/tests/permission-grant.spec.ts @@ -56,10 +56,11 @@ describe('PermissionGrant', () => { await testHarness.dwnEventLog.clear(); await testHarness.dwnMessageStore.clear(); await testHarness.dwnResumableTaskStore.clear(); + await testHarness.agent.permissions.clear(); testHarness.dwnStores.clear(); // create a random protocol URI for each run - protocolUri = `http://example.com/protocol/${TestDataGenerator.randomString(10)}`; + protocolUri = `http://example.com/protocol/${TestDataGenerator.randomString(15)}`; }); diff --git a/packages/api/tests/permission-request.spec.ts b/packages/api/tests/permission-request.spec.ts index a15289845..8cadb4c7e 100644 --- a/packages/api/tests/permission-request.spec.ts +++ b/packages/api/tests/permission-request.spec.ts @@ -52,10 +52,11 @@ describe('PermissionRequest', () => { await testHarness.dwnEventLog.clear(); await testHarness.dwnMessageStore.clear(); await testHarness.dwnResumableTaskStore.clear(); + await testHarness.agent.permissions.clear(); testHarness.dwnStores.clear(); // create a random protocol URI for each run - protocolUri = `http://example.com/protocol/${TestDataGenerator.randomString(10)}`; + protocolUri = `http://example.com/protocol/${TestDataGenerator.randomString(15)}`; }); diff --git a/packages/api/tests/protocol.spec.ts b/packages/api/tests/protocol.spec.ts index 7eba23c0e..541a77fc2 100644 --- a/packages/api/tests/protocol.spec.ts +++ b/packages/api/tests/protocol.spec.ts @@ -47,6 +47,7 @@ describe('Protocol', () => { await testHarness.dwnEventLog.clear(); await testHarness.dwnMessageStore.clear(); await testHarness.dwnResumableTaskStore.clear(); + await testHarness.agent.permissions.clear(); testHarness.dwnStores.clear(); }); @@ -58,7 +59,7 @@ describe('Protocol', () => { describe('send()', () => { it('configures protocols on remote DWNs for your own DID', async () => { // Alice configures a protocol on her agent connected DWN. - const protocolUri = `http://example.com/protocol/${TestDataGenerator.randomString(10)}`; + const protocolUri = `http://example.com/protocol/${TestDataGenerator.randomString(15)}`; const { status: aliceEmailStatus, protocol: aliceEmailProtocol } = await dwnAlice.protocols.configure({ message: { definition: { diff --git a/packages/api/tests/record.spec.ts b/packages/api/tests/record.spec.ts index 39f8028d1..871324aff 100644 --- a/packages/api/tests/record.spec.ts +++ b/packages/api/tests/record.spec.ts @@ -1,4 +1,4 @@ -import type { BearerDid } from '@web5/dids'; +import type { BearerDid ,PortableDid } from '@web5/dids'; import type { DwnMessageParams, DwnProtocolDefinition, DwnPublicKeyJwk, DwnSigner } from '@web5/agent'; import sinon from 'sinon'; @@ -6,7 +6,7 @@ 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, PlatformAgentTestHarness } from '@web5/agent'; +import { DwnConstant, DwnDateSort, DwnEncryptionAlgorithm, DwnInterface, DwnKeyDerivationScheme, dwnMessageConstructors, 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'; @@ -17,7 +17,8 @@ import emailProtocolDefinition from './fixtures/protocol-definitions/email.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 { Message } from '@tbd54566975/dwn-sdk-js'; +import { Jws, Message, Poller } from '@tbd54566975/dwn-sdk-js'; +import { Web5 } from '../src/web5.js'; // @ts-ignore if (!globalThis.crypto) globalThis.crypto = webcrypto; @@ -32,9 +33,15 @@ describe('Record', () => { let dwnAlice: DwnApi; let dwnBob: DwnApi; let testHarness: PlatformAgentTestHarness; - let protocolUri: string; + let protocolDefinition: DwnProtocolDefinition; + + let consoleWarn; before(async () => { + // Suppress console.warn output due to default password warnings + consoleWarn = console.warn; + console.warn = () => {}; + testHarness = await PlatformAgentTestHarness.setup({ agentClass : Web5UserAgent, agentStores : 'memory' @@ -68,14 +75,13 @@ describe('Record', () => { await testHarness.dwnEventLog.clear(); await testHarness.dwnMessageStore.clear(); await testHarness.dwnResumableTaskStore.clear(); + await testHarness.agent.permissions.clear(); testHarness.dwnStores.clear(); - - // give the protocol a random URI on each run - protocolUri = `http://example.com/protocol/${TestDataGenerator.randomString(10)}`; - const protocolDefinition = { + protocolDefinition = { ...emailProtocolDefinition, - protocol: protocolUri + protocol : `http://email-protocol.xyz/protocol/${TestDataGenerator.randomString(15)}`, + published : true }; // Configure the protocol on both DWNs @@ -84,10 +90,11 @@ describe('Record', () => { expect(aliceProtocol).to.exist; const { status: aliceProtocolSendStatus } = await aliceProtocol.send(aliceDid.uri); expect(aliceProtocolSendStatus.code).to.equal(202); + const { status: bobProtocolStatus, protocol: bobProtocol } = await dwnBob.protocols.configure({ message: { definition: protocolDefinition } }); expect(bobProtocolStatus.code).to.equal(202); expect(bobProtocol).to.exist; - const { status: bobProtocolSendStatus } = await bobProtocol!.send(bobDid.uri); + const { status: bobProtocolSendStatus } = await bobProtocol.send(bobDid.uri); expect(bobProtocolSendStatus.code).to.equal(202); }); @@ -95,6 +102,390 @@ describe('Record', () => { sinon.restore(); await testHarness.clearStorage(); await testHarness.closeStorage(); + + // Restore console.warn output + console.warn = consoleWarn; + }); + + describe('as delegateDid', () => { + let delegateHarness: PlatformAgentTestHarness; + let delegateDid: PortableDid; + let delegateDwn: DwnApi; + let notesProtocol: DwnProtocolDefinition; + + before(async () => { + delegateHarness = await PlatformAgentTestHarness.setup({ + agentClass : Web5UserAgent, + agentStores : 'memory', + testDataLocation : '__TESTDATA__/delegateDid' + }); + + await delegateHarness.clearStorage(); + await delegateHarness.createAgentDid(); + }); + + beforeEach(async () => { + sinon.restore(); + await delegateHarness.syncStore.clear(); + await delegateHarness.dwnDataStore.clear(); + await delegateHarness.dwnEventLog.clear(); + await delegateHarness.dwnMessageStore.clear(); + await delegateHarness.dwnResumableTaskStore.clear(); + await testHarness.agent.permissions.clear(); + delegateHarness.dwnStores.clear(); + + // avoid seeing the security warning of no password during connect + sinon.stub(console, 'warn'); + + notesProtocol = { + published : true, + protocol : `http://notes-protocol.xyz/protocol/${TestDataGenerator.randomString(15)}`, + types : { + note: { + schema : 'https://notes-protocol.xyz/schema/note', + dataFormats : [ 'text/plain', 'application/json' ] + } + }, + structure: { + note: {} + } + }; + + // Create a "device" JWK to use as the delegateDid + const delegatedBearerDid = await testHarness.agent.did.create({ store: false, method: 'jwk', }); + delegateDid = await delegatedBearerDid.export(); + + const grantRequest = WalletConnect.createPermissionRequestForProtocol({ + definition : notesProtocol, + permissions : ['write', 'read', 'delete', 'query', 'subscribe'] + }); + + // alice and bob both configure the protocol + const { status: aliceConfigStatus, protocol: aliceNotesProtocol } = await dwnAlice.protocols.configure({ message: { definition: notesProtocol } }); + expect(aliceConfigStatus.code).to.equal(202); + const { status: aliceNotesProtocolSend } = await aliceNotesProtocol.send(aliceDid.uri); + expect(aliceNotesProtocolSend.code).to.equal(202); + + const { status: bobConfigStatus, protocol: bobNotesProtocol } = await dwnBob.protocols.configure({ message: { definition: notesProtocol } }); + expect(bobConfigStatus.code).to.equal(202); + const { status: bobNotesProtocolSend } = await bobNotesProtocol!.send(bobDid.uri); + expect(bobNotesProtocolSend.code).to.equal(202); + + const grants = await Oidc.createPermissionGrants(aliceDid.uri, delegatedBearerDid, testHarness.agent, grantRequest.permissionScopes); + + sinon.stub(Web5UserAgent, 'create').resolves(delegateHarness.agent as Web5UserAgent); + sinon.stub(WalletConnect, 'createPermissionRequestForProtocol').resolves(grantRequest); + sinon.stub(delegateHarness.agent.identity, 'connectedIdentity').resolves(undefined); + sinon.stub(delegateHarness.agent.sync, 'startSync').resolves(); + // // stub WalletConnect.initClient to return the did and grants + sinon.stub(WalletConnect, 'initClient').resolves({ + connectedDid : aliceDid.uri, + delegatePortableDid : delegateDid, + delegateGrants : grants, + }); + + // connect with grants + ({ web5: { dwn: delegateDwn } } = await Web5.connect({ walletConnectOptions: { + permissionRequests: [ grantRequest ] + } as any })); + }); + + it('should update a record with a delegated grant', async () => { + const { status, record } = await delegateDwn.records.write({ + data : 'Hello, world!', + message : { + protocol : notesProtocol.protocol, + protocolPath : 'note', + schema : notesProtocol.types.note.schema, + dataFormat : 'text/plain', + } + }); + + const dataCidBeforeDataUpdate = record!.dataCid; + + expect(status.code).to.equal(202); + expect(record).to.not.be.undefined; + + // attempt to update the record with the delegated grant + const updateResult = await record!.update({ data: 'Delegate Updated' }); + expect(updateResult.status.code).to.equal(202); + + // attempt to read the record with the delegated grant + const readResult = await delegateDwn.records.read({ + protocol : notesProtocol.protocol, + message : { + filter: { + recordId: record!.id + } + } + }); + + expect(readResult.status.code).to.equal(200); + expect(readResult.record).to.not.be.undefined; + + expect(readResult.record.dataCid).to.not.equal(dataCidBeforeDataUpdate); + expect(readResult.record.dataCid).to.equal(record!.dataCid); + + // validate update signature is from the delegateDid but author is alice + const updateSignature = Jws.getSignerDid(readResult.record.rawMessage.authorization.signature.signatures[0]); + expect(updateSignature).to.equal(delegateDid.uri); + expect(readResult.record.author).to.equal(aliceDid.uri); + + const updatedData = await record!.data.text(); + expect(updatedData).to.equal('Delegate Updated'); + }); + + it('should delete a record with a delegated grant', async () => { + // alice writes a record + const { status, record } = await dwnAlice.records.write({ + data : 'Hello, world!', + message : { + protocol : notesProtocol.protocol, + protocolPath : 'note', + schema : notesProtocol.types.note.schema, + dataFormat : 'text/plain', + } + }); + + expect(status.code).to.equal(202); + expect(record).to.not.be.undefined; + + // alice sends the record to her remote + const sendResult = await record!.send(); + expect(sendResult.status.code).to.equal(202); + + // alice device queries alice remote for the record + const aliceDeviceRemoteQuery = await delegateDwn.records.query({ + from : aliceDid.uri, + protocol : notesProtocol.protocol, + message : { + filter: { + protocol : notesProtocol.protocol, + protocolPath : 'note', + } + } + }); + + expect(aliceDeviceRemoteQuery.status.code).to.equal(200); + expect(aliceDeviceRemoteQuery.records.length).to.equal(1); + const aliceRecord = aliceDeviceRemoteQuery.records[0]; + + // attempt to delete the record with the delegated grant + const deleteResult = await aliceRecord.delete(); + expect(deleteResult.status.code).to.equal(202, 'delete'); + + // send the delete to the remote DWN + const sendDeleteResult = await aliceRecord.send(); + expect(sendDeleteResult.status.code).to.equal(202, 'send delete'); + + // expect the delete to be signed by the delegateDid + const deleteSignature = Jws.getSignerDid(aliceRecord.rawMessage.authorization.signature.signatures[0]); + expect(deleteSignature).to.equal(delegateDid.uri); + + // attempt to read the record with the delegated grant + const readResult = await delegateDwn.records.read({ + protocol : notesProtocol.protocol, + message : { + filter: { + protocol : notesProtocol.protocol, + recordId : record!.id + } + } + }); + + expect(readResult.status.code).to.equal(404, 'read'); + expect(readResult.record).to.be.undefined; + + // attempt to query the record from the remote + const queryResult = await delegateDwn.records.query({ + from : aliceDid.uri, + protocol : notesProtocol.protocol, + message : { + filter: { + protocol : notesProtocol.protocol, + recordId : record!.id + } + } + }); + + expect(queryResult.status.code).to.equal(200, 'query'); + expect(queryResult.records.length).to.equal(0); + + + // attempt to delete again, record should return not found + const deleteResult2 = await aliceRecord.delete(); + expect(deleteResult2.status.code).to.equal(404, 'delete 2'); + }); + + it('should import a record with a delegated grant', async () => { + // bob writes a note with alice as the recipient + const { status: bobWriteStatus, record: bobRecord } = await dwnBob.records.write({ + data : 'Hello, Alice!', + message : { + protocol : notesProtocol.protocol, + protocolPath : 'note', + schema : notesProtocol.types.note.schema, + dataFormat : 'text/plain', + recipient : aliceDid.uri + } + }); + expect(bobWriteStatus.code).to.equal(202); + + // bob sends it to his remote DWN + const { status: bobSendStatus } = await bobRecord!.send(); + expect(bobSendStatus.code).to.equal(202); + + // confirm that alice delegate does not have it stored locally + let aliceDeviceLocal = await delegateDwn.records.query({ + protocol : notesProtocol.protocol, + message : { + filter: { + protocol : notesProtocol.protocol, + protocolPath : 'note', + } + } + }); + expect(aliceDeviceLocal.status.code).to.equal(200); + expect(aliceDeviceLocal.records.length).to.equal(0); + + // alice delegate is able to query for the note + const { records: aliceQueryFromBobRecords, status: aliceQueryFromBobStatus } = await delegateDwn.records.query({ + from : bobDid.uri, + protocol : notesProtocol.protocol, + message : { + filter: { + protocol : notesProtocol.protocol, + protocolPath : 'note', + } + } + }); + expect(aliceQueryFromBobStatus.code).to.equal(200); + expect(aliceQueryFromBobRecords).to.exist; + expect(aliceQueryFromBobRecords.length).to.equal(1); + + const recordFromBob = aliceQueryFromBobRecords[0]; + // alice delegate imports the note + const { status: importStatus } = await recordFromBob.import(); + expect(importStatus.code).to.equal(202); + + // confirm the note is stored locally + aliceDeviceLocal = await delegateDwn.records.query({ + protocol : notesProtocol.protocol, + message : { + filter: { + protocol : notesProtocol.protocol, + protocolPath : 'note', + } + } + }); + expect(aliceDeviceLocal.status.code).to.equal(200); + expect(aliceDeviceLocal.records.length).to.equal(1); + expect(aliceDeviceLocal.records[0].id).to.equal(recordFromBob.id); + }); + + it('should store a record with a delegated grant', async () => { + // alice writes a note + const { status: aliceWritesStatus, record: aliceRecord } = await dwnAlice.records.write({ + data : 'Hello, From Alice!', + message : { + protocol : notesProtocol.protocol, + protocolPath : 'note', + schema : notesProtocol.types.note.schema, + dataFormat : 'text/plain', + } + }); + expect(aliceWritesStatus.code).to.equal(202); + + // alice sends it to her remote DWN + const { status: aliceSendStatus } = await aliceRecord!.send(); + expect(aliceSendStatus.code).to.equal(202); + + // sanity: alice delegate does not have the note stored locally + let aliceDelegateResults = await delegateDwn.records.query({ + protocol : notesProtocol.protocol, + message : { + filter: { + protocol : notesProtocol.protocol, + protocolPath : 'note', + } + } + }); + expect(aliceDelegateResults.status.code).to.equal(200); + expect(aliceDelegateResults.records.length).to.equal(0); + + // alice delegate is able to query for the note + const { records: aliceQueryFromBobRecords, status: aliceQueryFromBobStatus } = await delegateDwn.records.query({ + from : aliceDid.uri, + protocol : notesProtocol.protocol, + message : { + filter: { + protocol : notesProtocol.protocol, + protocolPath : 'note', + } + } + }); + expect(aliceQueryFromBobStatus.code).to.equal(200); + expect(aliceQueryFromBobRecords).to.exist; + expect(aliceQueryFromBobRecords.length).to.equal(1); + + const recordFromBob = aliceQueryFromBobRecords[0]; + + // alicedevice stores the note locally + const { status: storeStatus } = await recordFromBob.store(); + expect(storeStatus.code).to.equal(202); + + // confirm the note is stored locally + aliceDelegateResults = await delegateDwn.records.query({ + protocol : notesProtocol.protocol, + message : { + filter: { + protocol : notesProtocol.protocol, + protocolPath : 'note', + } + } + }); + expect(aliceDelegateResults.status.code).to.equal(200); + expect(aliceDelegateResults.records.length).to.equal(1); + }); + + it('should read large data payloads as a stream with a delegated grant', async () => { + const largeDataJson = TestDataGenerator.randomJson(DwnConstant.maxDataSizeAllowedToBeEncoded + 1000); + const largeDataBytes = new TextEncoder().encode(JSON.stringify(largeDataJson)); + + // Write the large record to agent-connected DWN. + const { record, status } = await delegateDwn.records.write({ + message: { + protocol : notesProtocol.protocol, + protocolPath : 'note', + schema : notesProtocol.types.note.schema, + dataFormat : 'application/json', + }, + data: largeDataJson + }); + expect(status.code).to.equal(202, 'write'); + + // query for the record that was just created. queries don't come with the data stream so .stream() will be invoked + const { records: queryRecords, status: queryRecordStatus } = await delegateDwn.records.query({ + protocol : notesProtocol.protocol, + message : { + filter: { + protocol : notesProtocol.protocol, + protocolPath : 'note', + } + } + }); + expect(queryRecordStatus.code).to.equal(200, 'query'); + expect(queryRecords.length).to.equal(1); + const queriedRecord = queryRecords[0]; + + // Read the data stream JSON + const dataJson = await queriedRecord.data.json(); + expect(dataJson).to.deep.equal(largeDataJson, 'json'); + + // Read the data stream Bytes + const dataBytes = await queriedRecord.data.bytes(); + expect(dataBytes).to.deep.equal(largeDataBytes, 'bytes'); + }); }); it('imports a record that another user wrote', async () => { @@ -103,7 +494,7 @@ describe('Record', () => { data : TestDataGenerator.randomString(DwnConstant.maxDataSizeAllowedToBeEncoded + 1000), message : { recipient : bobDid.uri, - protocol : protocolUri, + protocol : protocolDefinition.protocol, protocolPath : 'thread', schema : 'http://email-protocol.xyz/schema/thread', } @@ -117,7 +508,7 @@ describe('Record', () => { from : bobDid.uri, message : { filter: { - protocol : protocolUri, + protocol : protocolDefinition.protocol, protocolPath : 'thread', } } @@ -130,7 +521,7 @@ describe('Record', () => { from : aliceDid.uri, message : { filter: { - protocol : protocolUri, + protocol : protocolDefinition.protocol, protocolPath : 'thread', } } @@ -152,7 +543,7 @@ describe('Record', () => { from : bobDid.uri, message : { filter: { - protocol : protocolUri, + protocol : protocolDefinition.protocol, protocolPath : 'thread', } } @@ -186,7 +577,7 @@ describe('Record', () => { from : aliceDid.uri, message : { filter: { - protocol : protocolUri, + protocol : protocolDefinition.protocol, protocolPath : 'thread', } } @@ -209,7 +600,7 @@ describe('Record', () => { from : bobDid.uri, message : { filter: { - protocol : protocolUri, + protocol : protocolDefinition.protocol, protocolPath : 'thread', } } @@ -259,9 +650,9 @@ describe('Record', () => { }; // RecordsWriteDescriptor properties that can be pre-defined - const protocol = protocolUri; + const protocol = protocolDefinition.protocol; const protocolPath = 'thread'; - const schema = emailProtocolDefinition.types.thread.schema; + const schema = protocolDefinition.types.thread.schema; const recipient = aliceDid.uri; const published = true; @@ -1178,13 +1569,9 @@ describe('Record', () => { await testHarnessCarol.dwnEventLog.clear(); await testHarnessCarol.dwnMessageStore.clear(); await testHarnessCarol.dwnResumableTaskStore.clear(); + await testHarness.agent.permissions.clear(); testHarnessCarol.dwnStores.clear(); - const protocolDefinition = { - ...emailProtocolDefinition, - protocol: protocolUri - }; - const { status: carolProtocolStatus, protocol: carolProtocol } = await dwnCarol.protocols.configure({ message: { definition: protocolDefinition } }); expect(carolProtocolStatus.code).to.equal(202); expect(carolProtocol).to.exist; @@ -1230,9 +1617,9 @@ describe('Record', () => { data : dataTextExceedingMaxSize, store : false, message : { - protocol : protocolUri, + protocol : protocolDefinition.protocol, protocolPath : 'thread', - schema : emailProtocolDefinition.types.thread.schema + schema : protocolDefinition.types.thread.schema } }); expect(status.code).to.equal(202); @@ -1289,9 +1676,9 @@ describe('Record', () => { data : dataTextExceedingMaxSize, store : false, message : { - protocol : protocolUri, + protocol : protocolDefinition.protocol, protocolPath : 'thread', - schema : emailProtocolDefinition.types.thread.schema + schema : protocolDefinition.types.thread.schema } }); expect(status.code).to.equal(202); @@ -1513,9 +1900,9 @@ describe('Record', () => { store : false, data : dataText, message : { - protocol : protocolUri, + protocol : protocolDefinition.protocol, protocolPath : 'thread', - schema : emailProtocolDefinition.types.thread.schema + schema : protocolDefinition.types.thread.schema } }); expect(aliceEmailStatus.code).to.equal(202); @@ -1541,8 +1928,8 @@ describe('Record', () => { from : bobDid.uri, message : { filter: { - protocol : protocolUri, - schema : emailProtocolDefinition.types.thread.schema + protocol : protocolDefinition.protocol, + schema : protocolDefinition.types.thread.schema } } }); @@ -1561,9 +1948,9 @@ describe('Record', () => { store : false, data : dataText, message : { - protocol : protocolUri, + protocol : protocolDefinition.protocol, protocolPath : 'thread', - schema : emailProtocolDefinition.types.thread.schema + schema : protocolDefinition.types.thread.schema } }); expect(aliceEmailStatus.code).to.equal(202); @@ -1588,8 +1975,8 @@ describe('Record', () => { from : bobDid.uri, message : { filter: { - protocol : protocolUri, - schema : emailProtocolDefinition.types.thread.schema + protocol : protocolDefinition.protocol, + schema : protocolDefinition.types.thread.schema } } }); @@ -1605,9 +1992,9 @@ describe('Record', () => { const { status: aliceEmailStatus, record: aliceEmailRecord } = await dwnAlice.records.write({ data : dataString, message : { - protocol : protocolUri, + protocol : protocolDefinition.protocol, protocolPath : 'thread', - schema : emailProtocolDefinition.types.thread.schema + schema : protocolDefinition.types.thread.schema } }); @@ -1622,8 +2009,8 @@ describe('Record', () => { from : bobDid.uri, message : { filter: { - protocol : protocolUri, - schema : emailProtocolDefinition.types.thread.schema + protocol : protocolDefinition.protocol, + schema : protocolDefinition.types.thread.schema } } }); @@ -1643,9 +2030,9 @@ describe('Record', () => { store : false, data : dataString, message : { - protocol : protocolUri, + protocol : protocolDefinition.protocol, protocolPath : 'thread', - schema : emailProtocolDefinition.types.thread.schema + schema : protocolDefinition.types.thread.schema } }); @@ -1659,9 +2046,9 @@ describe('Record', () => { const queryResult = await dwnAlice.records.query({ message: { filter: { - protocol : protocolUri, + protocol : protocolDefinition.protocol, protocolPath : 'thread', - schema : emailProtocolDefinition.types.thread.schema + schema : protocolDefinition.types.thread.schema } } }); @@ -1680,9 +2067,9 @@ describe('Record', () => { from : aliceDid.uri, message : { filter: { - protocol : protocolUri, + protocol : protocolDefinition.protocol, protocolPath : 'thread', - schema : emailProtocolDefinition.types.thread.schema + schema : protocolDefinition.types.thread.schema } } }); @@ -1702,9 +2089,9 @@ describe('Record', () => { store : false, data : dataString, message : { - protocol : protocolUri, + protocol : protocolDefinition.protocol, protocolPath : 'thread', - schema : emailProtocolDefinition.types.thread.schema + schema : protocolDefinition.types.thread.schema } }); @@ -1718,9 +2105,9 @@ describe('Record', () => { const queryResult = await dwnAlice.records.query({ message: { filter: { - protocol : protocolUri, + protocol : protocolDefinition.protocol, protocolPath : 'thread', - schema : emailProtocolDefinition.types.thread.schema + schema : protocolDefinition.types.thread.schema } } }); @@ -1739,9 +2126,9 @@ describe('Record', () => { from : bobDid.uri, message : { filter: { - protocol : protocolUri, + protocol : protocolDefinition.protocol, protocolPath : 'thread', - schema : emailProtocolDefinition.types.thread.schema + schema : protocolDefinition.types.thread.schema } } }); @@ -1761,9 +2148,9 @@ describe('Record', () => { store : true, data : dataString, message : { - protocol : protocolUri, + protocol : protocolDefinition.protocol, protocolPath : 'thread', - schema : emailProtocolDefinition.types.thread.schema + schema : protocolDefinition.types.thread.schema } }); @@ -1777,9 +2164,9 @@ describe('Record', () => { const queryResult = await dwnAlice.records.query({ message: { filter: { - protocol : protocolUri, + protocol : protocolDefinition.protocol, protocolPath : 'thread', - schema : emailProtocolDefinition.types.thread.schema + schema : protocolDefinition.types.thread.schema } } }); @@ -1800,9 +2187,9 @@ describe('Record', () => { from : aliceDid.uri, message : { filter: { - protocol : protocolUri, + protocol : protocolDefinition.protocol, protocolPath : 'thread', - schema : emailProtocolDefinition.types.thread.schema + schema : protocolDefinition.types.thread.schema } } }); @@ -1857,9 +2244,9 @@ describe('Record', () => { }; // RecordsWriteDescriptor properties that can be pre-defined - const protocol = protocolUri; + const protocol = protocolDefinition.protocol; const protocolPath = 'thread'; - const schema = emailProtocolDefinition.types.thread.schema; + const schema = protocolDefinition.types.thread.schema; const recipient = aliceDid.uri; const published = true; @@ -1958,9 +2345,9 @@ describe('Record', () => { const { record, status } = await dwnAlice.records.write({ data : 'Hello, world!', message : { - protocol : protocolUri, + protocol : protocolDefinition.protocol, protocolPath : 'thread', - schema : emailProtocolDefinition.types.thread.schema, + schema : protocolDefinition.types.thread.schema, dataFormat : 'text/plain' } }); @@ -2281,8 +2668,8 @@ describe('Record', () => { const { status: threadStatus, record: threadRecord } = await dwnAlice.records.write({ data : 'Hello, world!', message : { - protocol : protocolUri, - schema : emailProtocolDefinition.types.thread.schema, + protocol : protocolDefinition.protocol, + schema : protocolDefinition.types.thread.schema, protocolPath : 'thread' } }); @@ -2295,9 +2682,9 @@ describe('Record', () => { data : 'Hello, world!', message : { parentContextId : threadRecord.contextId, - protocol : protocolUri, + protocol : protocolDefinition.protocol, protocolPath : 'thread/email', - schema : emailProtocolDefinition.types.email.schema + schema : protocolDefinition.types.email.schema } }); expect(emailStatus.code).to.equal(202); @@ -2371,7 +2758,7 @@ describe('Record', () => { } }); - it('throws if a record status is deleted and initialWrite is not set', async () => { + it('throws if attempting to revive a deleted record', async () => { // create a record but do not store it const { status: writeStatus, record } = await dwnAlice.records.write({ store : false, @@ -2384,18 +2771,15 @@ describe('Record', () => { expect(writeStatus.code).to.equal(202); // delete the record but do not store it - const { status: deleteStatus } = await record.delete({ store: false }); + const { status: deleteStatus } = await record.delete(); expect(deleteStatus.code).to.equal(202); - // purposefully delete the _initialWrite property - delete record['_initialWrite']; - // store the record try { await record.update({ data: 'hi' }); expect.fail('Should have failed because the initial write is not set'); } catch (error: any) { - expect(error.message).to.include('If initial write is not set, the current rawRecord must be a RecordsWrite message.'); + expect(error.message).to.include('Record: Cannot revive a deleted record.'); } }); @@ -2490,6 +2874,47 @@ describe('Record', () => { }); describe('delete()', () => { + let notesProtocol: DwnProtocolDefinition; + + beforeEach(async () => { + const protocolUri = `http://example.com/notes-${TestDataGenerator.randomString(15)}`; + + notesProtocol = { + published : true, + protocol : protocolUri, + types : { + note: { + schema: 'http://example.com/note' + }, + request: { + schema: 'http://example.com/request' + } + }, + structure: { + request: { + $actions: [{ + who : 'anyone', + can : ['create', 'update', 'delete'] + }] + }, + note: { + } + } + }; + + // alice and bob both configure the protocol + const { status: aliceConfigStatus, protocol: aliceNotesProtocol } = await dwnAlice.protocols.configure({ message: { definition: notesProtocol } }); + expect(aliceConfigStatus.code).to.equal(202); + const { status: aliceNotesProtocolSend } = await aliceNotesProtocol.send(aliceDid.uri); + expect(aliceNotesProtocolSend.code).to.equal(202); + + const { status: bobConfigStatus, protocol: bobNotesProtocol } = await dwnBob.protocols.configure({ message: { definition: notesProtocol } }); + expect(bobConfigStatus.code).to.equal(202); + const { status: bobNotesProtocolSend } = await bobNotesProtocol!.send(bobDid.uri); + expect(bobNotesProtocolSend.code).to.equal(202); + + }); + it('deletes a local record on the local DWN', async () => { const { status: writeStatus, record } = await dwnAlice.records.write({ data : 'Hello, world!', @@ -2796,27 +3221,6 @@ describe('Record', () => { expect(childrenRecordsAfterDelete).to.have.lengthOf(0); }); - it('returns a 404 when the specified record does not exist', async () => { - // create a record but do not store it - const { status: createStatus, record } = await dwnAlice.records.write({ - store : false, - data : 'Hello, world!', - message : { - schema : 'foo/bar', - dataFormat : 'text/plain' - } - }); - - expect(createStatus.code).to.equal(202); - expect(record).to.not.be.undefined; - - const deleteResult = await record.delete(); - expect(deleteResult.status.code).to.equal(404); - - // confirm record is not in deleted state - expect(record.deleted).to.be.false; - }); - it('throws if a record status is deleted and initialWrite is not set', async () => { // create a record but do not store it const { status: writeStatus, record } = await dwnAlice.records.write({ @@ -2841,7 +3245,7 @@ describe('Record', () => { await record.delete(); expect.fail('Should have failed because the initial write is not set'); } catch (error: any) { - expect(error.message).to.include('If initial write is not set, the current rawRecord must be a RecordsWrite message.'); + expect(error.message).to.include('Record: Record is in an invalid state, initial write is missing.'); } }); @@ -2933,46 +3337,153 @@ describe('Record', () => { } }); - xit('signs a deleted message as owner'); + it('deletes a record from someone else', async () => { + // subscribe to records so that we can receive a record in a deleted state + const records = new Map(); + const subscriptionHandler = (record: Record) => { + records.set(record.id, record); + }; + + const { status, subscription } = await dwnAlice.records.subscribe({ + from : aliceDid.uri, + protocol : notesProtocol.protocol, + message : { + filter: { + protocol: notesProtocol.protocol, + } + }, + subscriptionHandler + }); + expect(status.code).to.equal(200, 'subscribe'); + + // bob writes a record for alice, alice deletes it and stores it + const { status: bobWriteStatus, record: bobWriteRecord } = await dwnBob.records.write({ + data : 'Hello, world!', + message : { + recipient : aliceDid.uri, + protocol : notesProtocol.protocol, + protocolPath : 'request', + schema : notesProtocol.types.request.schema, + dataFormat : 'text/plain' + } + }); + expect(bobWriteStatus.code).to.equal(202, 'write'); + + // send the record to alice's DWN + const { status: recordSend } = await bobWriteRecord.send(aliceDid.uri); + expect(recordSend.code).to.equal(202, 'send'); + + // wait for the record to be received + await Poller.pollUntilSuccessOrTimeout(async () => { + expect(records.size).to.equal(1); + const record = records.get(bobWriteRecord.id); + expect(record.toJSON()).to.deep.equal(bobWriteRecord.toJSON()); + }); + + // delete the record + const bobsRecordToDelete = records.get(bobWriteRecord.id); + expect(bobsRecordToDelete.deleted).to.be.false; + + const { status: storeStatus } = await bobsRecordToDelete.delete(); + expect(storeStatus.code).to.equal(202); + expect(bobsRecordToDelete.deleted).to.be.true; + + await subscription.close(); + }); + + it('deletes a record as owner from someone else', async () => { + // subscribe to records so that we can receive a record in a deleted state + const records = new Map(); + const subscriptionHandler = (record: Record) => { + records.set(record.id, record); + }; + + const { status, subscription } = await dwnAlice.records.subscribe({ + from : bobDid.uri, + protocol : notesProtocol.protocol, + message : { + filter: { + protocol: notesProtocol.protocol, + } + }, + subscriptionHandler + }); + expect(status.code).to.equal(200, 'subscribe'); + + // bob writes a record for alice, alice deletes it and stores it + const { status: bobWriteStatus, record: bobWriteRecord } = await dwnBob.records.write({ + data : 'Hello, world!', + message : { + recipient : aliceDid.uri, + protocol : notesProtocol.protocol, + protocolPath : 'note', + schema : notesProtocol.types.note.schema, + dataFormat : 'text/plain' + } + }); + expect(bobWriteStatus.code).to.equal(202, 'write'); + + // send the record to alice's DWN + const { status: recordSend } = await bobWriteRecord.send(bobDid.uri); + expect(recordSend.code).to.equal(202, 'send'); + + // wait for the record to be received + await Poller.pollUntilSuccessOrTimeout(async () => { + expect(records.size).to.equal(1); + const record = records.get(bobWriteRecord.id); + expect(record.toJSON()).to.deep.equal(bobWriteRecord.toJSON()); + }); + + // delete the record + const bobsRecordToDelete = records.get(bobWriteRecord.id); + expect(bobsRecordToDelete.deleted).to.be.false; + + const { status: storeStatus } = await bobsRecordToDelete.delete({ signAsOwner: true }); + expect(storeStatus.code).to.equal(202, 'delete'); + expect(bobsRecordToDelete.deleted).to.be.true; + + await subscription.close(); + }); }); describe('store()', () => { - let ownerOnlyProtocolUri: string; - // install a protocol that only the owner of the DWN should be able to writing records - const ownerOnlyProtocolDefinition: DwnProtocolDefinition = { - protocol : 'owner-only', - published : true, - types : { - note: { - schema : 'http://example.com/note', - dataFormats : ['text/plain', 'application/json'] - } - }, - structure: { - note: {} - } - }; + let notesProtocol: DwnProtocolDefinition; beforeEach(async () => { - - // give the protocol a random URI on each run - ownerOnlyProtocolUri = `http://example.com/protocol/${TestDataGenerator.randomString(10)}`; - const protocolDefinition = { - ...ownerOnlyProtocolDefinition, - protocol: ownerOnlyProtocolUri + const protocolUri = `http://example.com/notes-${TestDataGenerator.randomString(15)}`; + notesProtocol = { + published : true, + protocol : protocolUri, + types : { + note: { + schema: 'http://example.com/note' + }, + request: { + schema: 'http://example.com/request' + }, + }, + structure: { + request: { + $actions: [{ + who : 'anyone', + can : ['create', 'update', 'delete'] + }] + }, + note: { + } + } }; + // alice and bob both configure the protocol + const { status: aliceConfigStatus, protocol: aliceNotesProtocol } = await dwnAlice.protocols.configure({ message: { definition: notesProtocol } }); + expect(aliceConfigStatus.code).to.equal(202); + const { status: aliceNotesProtocolSend } = await aliceNotesProtocol.send(aliceDid.uri); + expect(aliceNotesProtocolSend.code).to.equal(202); - const { status: aliceProtocolStatus, protocol: aliceProtocol } = await dwnAlice.protocols.configure({ message: { definition: protocolDefinition } }); - expect(aliceProtocolStatus.code).to.equal(202); - expect(aliceProtocol).to.exist; - const { status: aliceProtocolSendStatus } = await aliceProtocol.send(aliceDid.uri); - expect(aliceProtocolSendStatus.code).to.equal(202); - const { status: bobProtocolStatus, protocol: bobProtocol } = await dwnBob.protocols.configure({ message: { definition: protocolDefinition } }); - expect(bobProtocolStatus.code).to.equal(202); - expect(bobProtocol).to.exist; - const { status: bobProtocolSendStatus } = await bobProtocol!.send(bobDid.uri); - expect(bobProtocolSendStatus.code).to.equal(202); + const { status: bobConfigStatus, protocol: bobNotesProtocol } = await dwnBob.protocols.configure({ message: { definition: notesProtocol } }); + expect(bobConfigStatus.code).to.equal(202); + const { status: bobNotesProtocolSend } = await bobNotesProtocol!.send(bobDid.uri); + expect(bobNotesProtocolSend.code).to.equal(202); }); it('should store an external record if it has been imported by the dwn owner', async () => { @@ -2984,9 +3495,9 @@ describe('Record', () => { data : 'Hello, world!', message : { published : true, - protocol : ownerOnlyProtocolUri, + protocol : notesProtocol.protocol, protocolPath : 'note', - schema : ownerOnlyProtocolDefinition.types.note.schema + schema : notesProtocol.types.note.schema } }); expect(status.code).to.equal(202, status.detail); @@ -3048,9 +3559,9 @@ describe('Record', () => { data : 'Hello, world!', message : { published : true, - protocol : ownerOnlyProtocolUri, + protocol : notesProtocol.protocol, protocolPath : 'note', - schema : ownerOnlyProtocolDefinition.types.note.schema + schema : notesProtocol.types.note.schema } }); expect(status.code).to.equal(202, status.detail); @@ -3120,9 +3631,9 @@ describe('Record', () => { store : false, data : 'Hello, world!', message : { - protocol : ownerOnlyProtocolUri, + protocol : notesProtocol.protocol, protocolPath : 'note', - schema : ownerOnlyProtocolDefinition.types.note.schema + schema : notesProtocol.types.note.schema } }); expect(writeStatus.code).to.equal(202); @@ -3130,7 +3641,7 @@ describe('Record', () => { // delete the record without storing const { status: deleteStatus } = await record.delete({ store: false }); - expect(deleteStatus.code).to.equal(202); + expect(deleteStatus.code).to.equal(202, 'delete not stored'); // check that the record is in a deleted state expect(record.deleted).to.be.true; @@ -3140,14 +3651,111 @@ describe('Record', () => { // store the record const { status: storeStatus } = await record.store(); - expect(storeStatus.code).to.equal(202); + expect(storeStatus.code).to.equal(202, 'delete stored'); // check that it was called once for initial write and once for the delete expect(processMessageSpy.callCount).to.equal(2); }); + + it('stores a deleted record as owner to the local DWN from an external signer', async () => { + // subscribe to records so that we can receive a record in a deleted state + const records = new Map(); + const subscriptionHandler = (record: Record) => { + records.set(record.id, record); + }; + + const { status, subscription } = await dwnAlice.records.subscribe({ + from : aliceDid.uri, + protocol : notesProtocol.protocol, + message : { + filter: { + protocol : notesProtocol.protocol, + protocolPath : 'request' + } + }, + subscriptionHandler + }); + expect(status.code).to.equal(200, 'subscribe'); + + // bob writes a record for alice, alice deletes it and stores it + const { status: bobWriteStatus, record: bobWriteRecord } = await dwnBob.records.write({ + data : 'Hello, world!', + message : { + recipient : aliceDid.uri, + protocol : notesProtocol.protocol, + protocolPath : 'request', + schema : notesProtocol.types.request.schema + } + }); + expect(bobWriteStatus.code).to.equal(202, 'write'); + + const { status: bobDeleteStatus } = await bobWriteRecord.delete(); + expect(bobDeleteStatus.code).to.equal(202, 'delete'); + + // send the deleted record to alice's DWN + const { status: deletedSend } = await bobWriteRecord.send(aliceDid.uri); + expect(deletedSend.code).to.equal(202, 'send'); + + // wait for the deleted record to be received + await Poller.pollUntilSuccessOrTimeout(async () => { + expect(records.size).to.equal(1); + const record = records.get(bobWriteRecord.id); + expect(record.deleted).to.be.true; + expect(record.toJSON()).to.deep.equal(bobWriteRecord.toJSON()); + }); + + // import the deleted record + const bobsRecordToDelete = records.get(bobWriteRecord.id); + expect(bobsRecordToDelete.deleted).to.be.true; + + const { status: storeStatus } = await bobsRecordToDelete.store(true); + expect(storeStatus.code).to.equal(202); + + await subscription.close(); + }); }); describe('import()', () => { + let notesProtocol: DwnProtocolDefinition; + + beforeEach(async () => { + const protocolUri = `https://example.com/protocol/${TestDataGenerator.randomString(15)}`; + notesProtocol = { + published : true, + protocol : protocolUri, + types : { + note: { + schema: 'http://example.com/note' + }, + request: { + schema: 'http://example.com/request' + } + }, + structure: { + request: { + $actions: [{ + who : 'anyone', + can : ['create', 'update', 'delete'] + }] + }, + note: { + } + } + }; + + // alice and bob both configure the protocol + const { status: aliceConfigStatus, protocol: aliceNotesProtocol } = await dwnAlice.protocols.configure({ message: { definition: notesProtocol } }); + expect(aliceConfigStatus.code).to.equal(202); + const { status: aliceNotesProtocolSend } = await aliceNotesProtocol.send(aliceDid.uri); + expect(aliceNotesProtocolSend.code).to.equal(202); + + const { status: bobConfigStatus, protocol: bobNotesProtocol } = await dwnBob.protocols.configure({ message: { definition: notesProtocol } }); + expect(bobConfigStatus.code).to.equal(202); + const { status: bobNotesProtocolSend } = await bobNotesProtocol!.send(bobDid.uri); + expect(bobNotesProtocolSend.code).to.equal(202); + + }); + it('should import an external record without storing it', async () => { // Scenario: Alice creates a record. // Bob queries for the record from Alice's DWN and then imports it without storing @@ -3158,9 +3766,9 @@ describe('Record', () => { data : 'Hello, world!', message : { published : true, - protocol : protocolUri, - protocolPath : 'thread', - schema : emailProtocolDefinition.types.thread.schema + protocol : notesProtocol.protocol, + protocolPath : 'note', + schema : notesProtocol.types.note.schema, } }); expect(status.code).to.equal(202, status.detail); @@ -3207,9 +3815,9 @@ describe('Record', () => { data : 'Hello, world!', message : { published : true, - protocol : protocolUri, - protocolPath : 'thread', - schema : emailProtocolDefinition.types.thread.schema + protocol : notesProtocol.protocol, + protocolPath : 'note', + schema : notesProtocol.types.note.schema, } }); expect(status.code).to.equal(202, status.detail); @@ -3250,41 +3858,63 @@ describe('Record', () => { expect(storedRecord.id).to.equal(record.id); }); - it('imports a deleted record to the local DWN along with the initial write', async () => { + it('signs and imports a deleted record as the owner', async () => { + // subscribe to records so that we can receive a record in a deleted state + const records = new Map(); + const subscriptionHandler = (record: Record) => { + records.set(record.id, record); + }; - // spy on the processMessage method to confirm it is called twice by the `import()` method - // once for the initial write and once for the delete - const processMessageSpy = sinon.spy(testHarness.dwn, 'processMessage'); + // subscribe to requests + const { status, subscription } = await dwnAlice.records.subscribe({ + from : aliceDid.uri, + protocol : notesProtocol.protocol, + message : { + filter: { + protocol : notesProtocol.protocol, + protocolPath : 'request' + } + }, + subscriptionHandler + }); + expect(status.code).to.equal(200, 'subscribe'); - // create a record - const { status: writeStatus, record } = await dwnAlice.records.write({ - store : false, + // bob writes a record for alice, alice deletes it and stores it + const { status: bobWriteStatus, record: bobWriteRecord } = await dwnBob.records.write({ data : 'Hello, world!', message : { - protocol : protocolUri, - protocolPath : 'thread', - schema : emailProtocolDefinition.types.thread.schema + recipient : aliceDid.uri, + protocol : notesProtocol.protocol, + protocolPath : 'request', + schema : notesProtocol.types.request.schema, + dataFormat : 'text/plain' } }); - expect(writeStatus.code).to.equal(202); - expect(record).to.exist; + expect(bobWriteStatus.code).to.equal(202, 'write'); - // delete the record without storing - const { status: deleteStatus } = await record.delete({ store: false }); - expect(deleteStatus.code).to.equal(202); + const { status: bobDeleteStatus } = await bobWriteRecord.delete(); + expect(bobDeleteStatus.code).to.equal(202, 'delete'); - // check that the record is in a deleted state - expect(record.deleted).to.be.true; + // send the deleted record to alice's DWN + const { status: deletedSend } = await bobWriteRecord.send(aliceDid.uri); + expect(deletedSend.code).to.equal(202, 'send'); - // dwn processMessage should not have been called yet as it hasn't been stored - expect(processMessageSpy.callCount).to.equal(0); + // wait for the deleted record to be received + await Poller.pollUntilSuccessOrTimeout(async () => { + expect(records.size).to.equal(1); + const record = records.get(bobWriteRecord.id); + expect(record.deleted).to.be.true; + expect(record.toJSON()).to.deep.equal(bobWriteRecord.toJSON()); + }); - // store the record - const { status: importedStatus } = await record.import(); - expect(importedStatus.code).to.equal(202); + // import the deleted record + const bobsRecordToDelete = records.get(bobWriteRecord.id); + expect(bobsRecordToDelete.deleted).to.be.true; - // check that it was called once for initial write and once for the delete - expect(processMessageSpy.callCount).to.equal(2); + const { status: importStatus } = await bobsRecordToDelete.import(); + expect(importStatus.code).to.equal(202); + + await subscription.close(); }); describe('store: false', () => { @@ -3298,9 +3928,9 @@ describe('Record', () => { data : 'Hello, world!', message : { published : true, - protocol : protocolUri, - protocolPath : 'thread', - schema : emailProtocolDefinition.types.thread.schema + protocol : notesProtocol.protocol, + protocolPath : 'note', + schema : notesProtocol.types.note.schema } }); expect(status.code).to.equal(202, status.detail); @@ -3363,9 +3993,9 @@ describe('Record', () => { data : 'Hello, world!', message : { published : true, - protocol : protocolUri, - protocolPath : 'thread', - schema : emailProtocolDefinition.types.thread.schema + protocol : notesProtocol.protocol, + protocolPath : 'note', + schema : notesProtocol.types.note.schema } }); expect(status.code).to.equal(202, status.detail); @@ -3420,6 +4050,67 @@ describe('Record', () => { const storedRecord = bobQueryResult.records[0]; expect(storedRecord.id).to.equal(record.id); }); + + it('signs and an external deleted record as the owner', async () => { + // subscribe to records so that we can receive a record in a deleted state + const records = new Map(); + const subscriptionHandler = (record: Record) => { + records.set(record.id, record); + }; + + const { status, subscription } = await dwnAlice.records.subscribe({ + from : aliceDid.uri, + protocol : notesProtocol.protocol, + message : { + filter: { + protocol : notesProtocol.protocol, + protocolPath : 'request' + } + }, + subscriptionHandler + }); + expect(status.code).to.equal(200, 'subscribe'); + + // bob writes a record for alice, alice deletes it and stores it + const { status: bobWriteStatus, record: bobWriteRecord } = await dwnBob.records.write({ + data : 'Hello, world!', + message : { + recipient : aliceDid.uri, + protocol : notesProtocol.protocol, + protocolPath : 'request', + schema : notesProtocol.types.request.schema, + dataFormat : 'text/plain' + } + }); + expect(bobWriteStatus.code).to.equal(202, 'write'); + + const { status: bobDeleteStatus } = await bobWriteRecord.delete(); + expect(bobDeleteStatus.code).to.equal(202, 'delete'); + + // send the deleted record to alice's DWN + const { status: deletedSend } = await bobWriteRecord.send(aliceDid.uri); + expect(deletedSend.code).to.equal(202, 'send'); + + // wait for the deleted record to be received + await Poller.pollUntilSuccessOrTimeout(async () => { + expect(records.size).to.equal(1); + const record = records.get(bobWriteRecord.id); + expect(record.deleted).to.be.true; + expect(record.toJSON()).to.deep.equal(bobWriteRecord.toJSON()); + }); + + // import the deleted record + const bobsRecordToDelete = records.get(bobWriteRecord.id); + expect(bobsRecordToDelete.deleted).to.be.true; + + const { status: importStatus } = await bobsRecordToDelete.import(false); + expect(importStatus.code).to.equal(202); + + const { status: storeStatus } = await bobsRecordToDelete.store(); + expect(storeStatus.code).to.equal(202); + + await subscription.close(); + }); }); }); @@ -3429,9 +4120,9 @@ describe('Record', () => { const { status, record } = await dwnAlice.records.write({ data : 'Hello, world!', message : { - protocol : protocolUri, + protocol : protocolDefinition.protocol, protocolPath : 'thread', - schema : emailProtocolDefinition.types.thread.schema + schema : protocolDefinition.types.thread.schema } }); @@ -3457,9 +4148,9 @@ describe('Record', () => { data : 'Hello, world!', message : { published : true, - protocol : protocolUri, + protocol : protocolDefinition.protocol, protocolPath : 'thread', - schema : emailProtocolDefinition.types.thread.schema + schema : protocolDefinition.types.thread.schema } }); expect(status.code).to.equal(202); @@ -3496,9 +4187,9 @@ describe('Record', () => { store : false, data : 'Hello, world!', message : { - protocol : protocolUri, + protocol : protocolDefinition.protocol, protocolPath : 'thread', - schema : emailProtocolDefinition.types.thread.schema + schema : protocolDefinition.types.thread.schema } }); expect(writeStatus.code).to.equal(202);