diff --git a/.changeset/many-suns-think.md b/.changeset/many-suns-think.md new file mode 100644 index 000000000..02246e9b7 --- /dev/null +++ b/.changeset/many-suns-think.md @@ -0,0 +1,8 @@ +--- +"@web5/agent": patch +"@web5/identity-agent": patch +"@web5/proxy-agent": patch +"@web5/user-agent": patch +--- + +Add `getProtocolRole` util diff --git a/.changeset/slimy-mayflies-hide.md b/.changeset/slimy-mayflies-hide.md new file mode 100644 index 000000000..64357adce --- /dev/null +++ b/.changeset/slimy-mayflies-hide.md @@ -0,0 +1,5 @@ +--- +"@web5/api": patch +--- + +Ensure protocolRole is maintained between query/read and subscribe/read. diff --git a/packages/agent/src/utils.ts b/packages/agent/src/utils.ts index 2ab38757f..a72c9f467 100644 --- a/packages/agent/src/utils.ts +++ b/packages/agent/src/utils.ts @@ -1,5 +1,5 @@ import type { DidUrlDereferencer } from '@web5/dids'; -import { PaginationCursor, RecordsDeleteMessage, RecordsWriteMessage } from '@tbd54566975/dwn-sdk-js'; +import { Jws, PaginationCursor, RecordsDeleteMessage, RecordsWriteMessage } from '@tbd54566975/dwn-sdk-js'; import { Readable } from '@web5/common'; import { utils as didUtils } from '@web5/dids'; @@ -42,6 +42,14 @@ export function getRecordAuthor(record: RecordsWriteMessage | RecordsDeleteMessa return Message.getAuthor(record); } +/** + * Get the `protocolRole` string from the signature payload of the given RecordsWriteMessage or RecordsDeleteMessage. + */ +export function getRecordProtocolRole(message: RecordsWriteMessage | RecordsDeleteMessage): string | undefined { + const signaturePayload = Jws.decodePlainObjectPayload(message.authorization.signature); + return signaturePayload?.protocolRole; +} + export function isRecordsWrite(obj: unknown): obj is RecordsWrite { // Validate that the given value is an object. if (!obj || typeof obj !== 'object' || obj === null) return false; diff --git a/packages/agent/tests/utils.spec.ts b/packages/agent/tests/utils.spec.ts index ebefcf424..3751613e8 100644 --- a/packages/agent/tests/utils.spec.ts +++ b/packages/agent/tests/utils.spec.ts @@ -1,9 +1,18 @@ import { expect } from 'chai'; +import sinon from 'sinon'; -import { DateSort, Message, TestDataGenerator } from '@tbd54566975/dwn-sdk-js'; -import { getPaginationCursor, getRecordAuthor, getRecordMessageCid } from '../src/utils.js'; +import { DateSort, Jws, Message, TestDataGenerator } from '@tbd54566975/dwn-sdk-js'; +import { getPaginationCursor, getRecordAuthor, getRecordMessageCid, getRecordProtocolRole } from '../src/utils.js'; describe('Utils', () => { + beforeEach(() => { + sinon.restore(); + }); + + after(() => { + sinon.restore(); + }); + describe('getPaginationCursor', () => { it('should return a PaginationCursor object', async () => { // create a RecordWriteMessage object which is published @@ -84,4 +93,35 @@ describe('Utils', () => { expect(deleteAuthorFromFunction!).to.equal(recordsDeleteAuthor.did); }); }); + + describe('getRecordProtocolRole', () => { + it('gets a protocol role from a RecordsWrite', async () => { + const recordsWrite = await TestDataGenerator.generateRecordsWrite({ protocolRole: 'some-role' }); + const role = getRecordProtocolRole(recordsWrite.message); + expect(role).to.equal('some-role'); + }); + + it('gets a protocol role from a RecordsDelete', async () => { + const recordsDelete = await TestDataGenerator.generateRecordsDelete({ protocolRole: 'some-role' }); + const role = getRecordProtocolRole(recordsDelete.message); + expect(role).to.equal('some-role'); + }); + + it('returns undefined if no role is defined', async () => { + const recordsWrite = await TestDataGenerator.generateRecordsWrite(); + const writeRole = getRecordProtocolRole(recordsWrite.message); + expect(writeRole).to.be.undefined; + + const recordsDelete = await TestDataGenerator.generateRecordsDelete(); + const deleteRole = getRecordProtocolRole(recordsDelete.message); + expect(deleteRole).to.be.undefined; + }); + + it('returns undefined if decodedObject is undefined', async () => { + sinon.stub(Jws, 'decodePlainObjectPayload').returns(undefined); + const recordsWrite = await TestDataGenerator.generateRecordsWrite(); + const writeRole = getRecordProtocolRole(recordsWrite.message); + expect(writeRole).to.be.undefined; + }); + }); }); \ No newline at end of file diff --git a/packages/api/src/dwn-api.ts b/packages/api/src/dwn-api.ts index e91f21622..96d0e62a6 100644 --- a/packages/api/src/dwn-api.ts +++ b/packages/api/src/dwn-api.ts @@ -699,6 +699,7 @@ export class DwnApi { */ remoteOrigin : request.from, delegateDid : this.delegateDid, + protocolRole : agentRequest.messageParams.protocolRole, ...entry as DwnMessage[DwnInterface.RecordsWrite] }; const record = new Record(this.agent, recordOptions, this.permissionsApi); @@ -829,6 +830,7 @@ export class DwnApi { connectedDid : this.connectedDid, delegateDid : this.delegateDid, permissionsApi : this.permissionsApi, + protocolRole : request.message.protocolRole, request }) }; diff --git a/packages/api/src/record.ts b/packages/api/src/record.ts index 47b703c24..e93b2463f 100644 --- a/packages/api/src/record.ts +++ b/packages/api/src/record.ts @@ -21,6 +21,7 @@ import { SendDwnRequest, PermissionsApi, AgentPermissionsApi, + getRecordProtocolRole } from '@web5/agent'; import { Convert, isEmptyObject, NodeStream, removeUndefinedProperties, Stream } from '@web5/common'; @@ -183,6 +184,9 @@ export type RecordDeleteParams = { /** The timestamp indicating when the record was deleted. */ dateModified?: DwnMessageDescriptor[DwnInterface.RecordsDelete]['messageTimestamp']; + + /** The protocol role under which this record will be deleted. */ + protocolRole?: string; }; /** @@ -311,7 +315,6 @@ export class Record implements RecordModel { /** Tags of the record */ get tags() { return this._recordsWriteDescriptor?.tags; } - // Getters for for properties that depend on the current state of the Record. /** DID that is the logical author of the Record. */ get author(): string { return this._author; } @@ -703,7 +706,7 @@ export class Record implements RecordModel { * * @beta */ - async update({ dateModified, data, ...params }: RecordUpdateParams): Promise { + async update({ dateModified, data, protocolRole, ...params }: RecordUpdateParams): Promise { if (this.deleted) { throw new Error('Record: Cannot revive a deleted record.'); @@ -718,6 +721,7 @@ export class Record implements RecordModel { ...descriptor, ...params, parentContextId, + protocolRole : protocolRole ?? this._protocolRole, // Use the current protocolRole if not provided. messageTimestamp : dateModified, // Map Record class `dateModified` property to DWN SDK `messageTimestamp` recordId : this._recordId }; @@ -786,7 +790,7 @@ export class Record implements RecordModel { // Only update the local Record instance mutable properties if the record was successfully (over)written. this._authorization = responseMessage.authorization; - this._protocolRole = params.protocolRole; + this._protocolRole = updateMessage.protocolRole; mutableDescriptorProperties.forEach(property => { this._descriptor[property] = responseMessage.descriptor[property]; }); @@ -834,8 +838,11 @@ export class Record implements RecordModel { store }; - if (this.deleted) { - // if we have a delete message we can just use it + // Check to see if the provided protocolRole within the deleteParams is different from the current protocolRole. + const differentRole = deleteParams?.protocolRole ? getRecordProtocolRole(this.rawMessage) !== deleteParams.protocolRole : false; + // If the record is already in a deleted state but the protocolRole is different, we need to construct a delete message with the new protocolRole + // otherwise we can just use the existing delete message. + if (this.deleted && !differentRole) { deleteOptions.rawMessage = this.rawMessage as DwnMessage[DwnInterface.RecordsDelete]; } else { // otherwise we construct a delete message given the `RecordDeleteParams` @@ -843,6 +850,7 @@ export class Record implements RecordModel { prune : prune, recordId : this._recordId, messageTimestamp : dateModified, + protocolRole : deleteParams?.protocolRole ?? this._protocolRole // if no protocolRole is provided, use the current protocolRole }; } @@ -1023,7 +1031,7 @@ export class Record implements RecordModel { private async readRecordData({ target, isRemote }: { target: string, isRemote: boolean }) { const readRequest: ProcessDwnRequest = { author : this._connectedDid, - messageParams : { filter: { recordId: this.id } }, + messageParams : { filter: { recordId: this.id }, protocolRole: this._protocolRole }, messageType : DwnInterface.RecordsRead, target, }; diff --git a/packages/api/src/subscription-util.ts b/packages/api/src/subscription-util.ts index 5316733d0..88a6f16d2 100644 --- a/packages/api/src/subscription-util.ts +++ b/packages/api/src/subscription-util.ts @@ -9,10 +9,11 @@ export class SubscriptionUtil { /** * Creates a record subscription handler that can be used to process incoming {Record} messages. */ - static recordSubscriptionHandler({ agent, connectedDid, request, delegateDid, permissionsApi }:{ + static recordSubscriptionHandler({ agent, connectedDid, request, delegateDid, protocolRole, permissionsApi }:{ agent: Web5Agent; connectedDid: string; delegateDid?: string; + protocolRole?: string; permissionsApi?: PermissionsApi; request: RecordsSubscribeRequest; }): DwnRecordSubscriptionHandler { @@ -31,6 +32,7 @@ export class SubscriptionUtil { const record = new Record(agent, { ...message, ...recordOptions, + protocolRole, delegateDid: delegateDid, }, permissionsApi); diff --git a/packages/api/tests/dwn-api.spec.ts b/packages/api/tests/dwn-api.spec.ts index 7f332c000..e2fbb3730 100644 --- a/packages/api/tests/dwn-api.spec.ts +++ b/packages/api/tests/dwn-api.spec.ts @@ -3,13 +3,14 @@ import type { BearerDid } from '@web5/dids'; import sinon from 'sinon'; import { expect } from 'chai'; import { Web5UserAgent } from '@web5/user-agent'; -import { AgentPermissionsApi, DwnDateSort, DwnProtocolDefinition, getRecordAuthor, Oidc, PlatformAgentTestHarness, WalletConnect } from '@web5/agent'; +import { AgentPermissionsApi, DwnDateSort, DwnInterface, DwnProtocolDefinition, getRecordAuthor, Oidc, PlatformAgentTestHarness, ProcessDwnRequest, WalletConnect } from '@web5/agent'; import { DwnApi } from '../src/dwn-api.js'; import { testDwnUrl } from './utils/test-config.js'; import emailProtocolDefinition from './fixtures/protocol-definitions/email.json' assert { type: 'json' }; import photosProtocolDefinition from './fixtures/protocol-definitions/photos.json' assert { type: 'json' }; -import { DwnInterfaceName, DwnMethodName, Jws, PermissionsProtocol, Poller, Time } from '@tbd54566975/dwn-sdk-js'; +import notesProtocolDefinition from './fixtures/protocol-definitions/notes.json' assert { type: 'json' }; +import { DwnConstant, DwnInterfaceName, DwnMethodName, Jws, PermissionsProtocol, Poller, Time } from '@tbd54566975/dwn-sdk-js'; import { PermissionGrant } from '../src/permission-grant.js'; import { Record } from '../src/record.js'; import { TestDataGenerator } from './utils/test-data-generator.js'; @@ -2079,6 +2080,98 @@ describe('DwnApi', () => { expect(fooBarResult.records![0].id).to.equal(record.id); expect(fooBarResult.records![0].tags).to.deep.equal({ foo: 'bar' }); }); + + it('ensures that a protocolRole used to query is also used to read the data of the resulted records', async () => { + // scenario: Bob has a protocol where he can write notes and add friends who can query and read these notes + // Alice is a friend of Bob and she queries for the notes and reads the data of the notes + // the protocolRole used to query for the notes should also be used to read the data of the notes + + const protocol = { + ...notesProtocolDefinition, + protocol: 'http://example.com/notes' + TestDataGenerator.randomString(15) + }; + + // Bob configures the notes protocol for himself + const { status: bobProtocolStatus, protocol: bobProtocol } = await dwnBob.protocols.configure({ + message: { + definition: protocol + } + }); + expect(bobProtocolStatus.code).to.equal(202); + const { status: bobRemoteProtocolStatus } = await bobProtocol.send(bobDid.uri); + expect(bobRemoteProtocolStatus.code).to.equal(202); + + // Bob creates a few notes ensuring that the data is larger than the max encoded size + // that way the data will be requested with a separate `read` request + const recordData: Map = new Map(); + for (let i = 0; i < 3; i++) { + const data = TestDataGenerator.randomString(DwnConstant.maxDataSizeAllowedToBeEncoded + 1); + const { status: noteCreateStatus, record: noteRecord } = await dwnBob.records.create({ + data, + message: { + protocol : protocol.protocol, + protocolPath : 'note', + schema : protocol.types.note.schema, + dataFormat : 'text/plain', + } + }); + expect(noteCreateStatus.code).to.equal(202); + const { status: noteSendStatus } = await noteRecord.send(); + expect(noteSendStatus.code).to.equal(202); + recordData.set(noteRecord.id, data); + } + + // Bob makes Alice a `friend` to allow her to read and comment on his notes + const { status: friendCreateStatus, record: friendRecord} = await dwnBob.records.create({ + data : 'friend!', + message : { + recipient : aliceDid.uri, + protocol : protocol.protocol, + protocolPath : 'friend', + schema : protocol.types.friend.schema, + dataFormat : 'text/plain' + } + }); + expect(friendCreateStatus.code).to.equal(202); + const { status: bobFriendSendStatus } = await friendRecord.send(bobDid.uri); + expect(bobFriendSendStatus.code).to.equal(202); + + // alice uses the role to query for the available notes + const { status: notesQueryStatus, records: noteRecords } = await dwnAlice.records.query({ + from : bobDid.uri, + message : { + protocolRole : 'friend', + filter : { + protocol : protocol.protocol, + protocolPath : 'note' + } + } + }); + expect(notesQueryStatus.code).to.equal(200); + expect(noteRecords).to.exist; + expect(noteRecords).to.have.lengthOf(3); + + // spy on sendDwnRequest to ensure that the protocolRole is used to read the data of the notes + const sendDwnRequestSpy = sinon.spy(testHarness.agent, 'sendDwnRequest'); + + // confirm that it starts with 0 calls + expect(sendDwnRequestSpy.callCount).to.equal(0); + // Alice attempts to read the data of the notes, which should succeed + for (const record of noteRecords) { + const readResult = await record.data.text(); + const expectedData = recordData.get(record.id); + expect(readResult).to.equal(expectedData); + } + + // confirm that it was called 3 times + expect(sendDwnRequestSpy.callCount).to.equal(3); + + // confirm that the protocolRole was used to read the data of the notes + expect(sendDwnRequestSpy.getCalls().every(call => + call.args[0].messageType === DwnInterface.RecordsRead && + (call.args[0] as ProcessDwnRequest).messageParams.protocolRole === 'friend' + )).to.be.true; + }); }); }); @@ -2445,6 +2538,107 @@ describe('DwnApi', () => { expect(record.deleted).to.be.false; }); }); + + it('ensures that a protocolRole used to subscribe is also used to read the data of the resulted records', async () => { + // scenario: Bob has a protocol where he can write notes and add friends who can subscribe and read these notes + // When Alice subscribes to the notes protocol using the role, the role should also be used to read the data of the notes + + const protocol = { + ...notesProtocolDefinition, + protocol: 'http://example.com/notes' + TestDataGenerator.randomString(15) + }; + + // Bob configures the notes protocol for himself + const { status: bobProtocolStatus, protocol: bobProtocol } = await dwnBob.protocols.configure({ + message: { + definition: protocol + } + }); + expect(bobProtocolStatus.code).to.equal(202); + const { status: bobRemoteProtocolStatus } = await bobProtocol.send(bobDid.uri); + expect(bobRemoteProtocolStatus.code).to.equal(202); + + + // Bob makes Alice a `friend` to allow her to read and comment on his notes + const { status: friendCreateStatus, record: friendRecord} = await dwnBob.records.create({ + data : 'friend!', + message : { + recipient : aliceDid.uri, + protocol : protocol.protocol, + protocolPath : 'friend', + schema : protocol.types.friend.schema, + dataFormat : 'text/plain' + } + }); + expect(friendCreateStatus.code).to.equal(202); + const { status: bobFriendSendStatus } = await friendRecord.send(bobDid.uri); + expect(bobFriendSendStatus.code).to.equal(202); + + // Alice subscribes to the notes protocol using the role + const notes: Map = new Map(); + const { status: notesSubscribeStatus, subscription } = await dwnAlice.records.subscribe({ + from : bobDid.uri, + message : { + protocolRole : 'friend', + filter : { + protocol : protocol.protocol, + protocolPath : 'note' + } + }, + subscriptionHandler: (record) => { + // add to the notes map + notes.set(record.id, record); + } + }); + expect(notesSubscribeStatus.code).to.equal(200); + expect(subscription).to.exist; + + // Bob creates a few notes ensuring that the data is larger than the max encoded size + // that way the data will be requested with a separate `read` request + const recordData: Map = new Map(); + for (let i = 0; i < 3; i++) { + const data = TestDataGenerator.randomString(DwnConstant.maxDataSizeAllowedToBeEncoded + 1); + const { status: noteCreateStatus, record: noteRecord } = await dwnBob.records.create({ + data, + message: { + protocol : protocol.protocol, + protocolPath : 'note', + schema : protocol.types.note.schema, + dataFormat : 'text/plain', + } + }); + expect(noteCreateStatus.code).to.equal(202); + const { status: noteSendStatus } = await noteRecord.send(); + expect(noteSendStatus.code).to.equal(202); + recordData.set(noteRecord.id, data); + } + + // poll for the note records to be received + await Poller.pollUntilSuccessOrTimeout(async () => { + expect(notes.size).to.equal(3); + }); + + // spy on sendDwnRequest to ensure that the protocolRole is used to read the data of the notes + const sendDwnRequestSpy = sinon.spy(testHarness.agent, 'sendDwnRequest'); + + // confirm that it starts with 0 calls + expect(sendDwnRequestSpy.callCount).to.equal(0); + // Alice attempts to read the data of the notes, which should succeed + for (const record of notes.values()) { + const readResult = await record.data.text(); + const expectedData = recordData.get(record.id); + expect(readResult).to.equal(expectedData); + } + + // confirm that it was called 3 times + expect(sendDwnRequestSpy.callCount).to.equal(3); + + // confirm that the protocolRole was used to read the data of the notes + expect(sendDwnRequestSpy.getCalls().every(call => + call.args[0].messageType === DwnInterface.RecordsRead && + (call.args[0] as ProcessDwnRequest).messageParams.protocolRole === 'friend' + )).to.be.true; + }); }); }); diff --git a/packages/api/tests/fixtures/protocol-definitions/notes.json b/packages/api/tests/fixtures/protocol-definitions/notes.json new file mode 100644 index 000000000..cdea6f33c --- /dev/null +++ b/packages/api/tests/fixtures/protocol-definitions/notes.json @@ -0,0 +1,65 @@ +{ + "protocol": "http://notes-protocol.xyz", + "published": true, + "types": { + "note": { + "schema": "http://notes-protocol.xyz/schema/note", + "dataFormats": [ + "text/plain", + "application/json" + ] + }, + "comment": { + "schema": "http://notes-protocol.xyz/schema/comment", + "dataFormats": [ + "text/plain", + "application/json" + ] + }, + "friend" : { + "schema": "http://notes-protocol.xyz/schema/friend", + "dataFormats": [ + "text/plain", + "application/json" + ] + }, + "coAuthor" : { + "schema": "http://notes-protocol.xyz/schema/coAuthor", + "dataFormats": [ + "text/plain", + "application/json" + ] + } + }, + "structure": { + "friend" :{ + "$role": true + }, + "note": { + "coAuthor" : { + "$role": true + }, + "$actions": [ + { + "role": "friend", + "can": ["read", "query", "subscribe"] + }, + { + "role": "note/coAuthor", + "can": [ "co-update", "co-delete" ] + } + ], + "comment": { + "$actions": [ + { + "role": "friend", + "can": ["create", "update", "delete", "read", "query", "subscribe"] + }, { + "role": "note/coAuthor", + "can": ["create", "update", "delete", "co-delete", "read", "query", "subscribe"] + } + ] + } + } + } +} \ No newline at end of file diff --git a/packages/api/tests/record.spec.ts b/packages/api/tests/record.spec.ts index 7d147d633..8a14b5bb0 100644 --- a/packages/api/tests/record.spec.ts +++ b/packages/api/tests/record.spec.ts @@ -1,23 +1,24 @@ import type { BearerDid ,PortableDid } from '@web5/dids'; -import type { DwnMessageParams, DwnProtocolDefinition, DwnPublicKeyJwk, DwnSigner } from '@web5/agent'; +import type { DwnMessageParams, DwnProtocolDefinition, DwnPublicKeyJwk, DwnSigner, ProcessDwnRequest } from '@web5/agent'; import sinon from 'sinon'; import { expect } from 'chai'; import { NodeStream } from '@web5/common'; import { utils as didUtils } from '@web5/dids'; import { Web5UserAgent } from '@web5/user-agent'; -import { DwnConstant, DwnDateSort, DwnEncryptionAlgorithm, DwnInterface, DwnKeyDerivationScheme, dwnMessageConstructors, getRecordAuthor, Oidc, PlatformAgentTestHarness, WalletConnect } from '@web5/agent'; +import { DwnConstant, DwnDateSort, DwnEncryptionAlgorithm, DwnInterface, DwnKeyDerivationScheme, dwnMessageConstructors, getRecordAuthor, getRecordProtocolRole, Oidc, PlatformAgentTestHarness, WalletConnect } from '@web5/agent'; import { Record } from '../src/record.js'; import { DwnApi } from '../src/dwn-api.js'; import { dataToBlob } from '../src/utils.js'; import { testDwnUrl } from './utils/test-config.js'; import { TestDataGenerator } from './utils/test-data-generator.js'; import emailProtocolDefinition from './fixtures/protocol-definitions/email.json' assert { type: 'json' }; +import notesProtocolDefinition from './fixtures/protocol-definitions/notes.json' assert { type: 'json' }; // NOTE: @noble/secp256k1 requires globalThis.crypto polyfill for node.js <=18: https://github.com/paulmillr/noble-secp256k1/blob/main/README.md#usage // Remove when we move off of node.js v18 to v20, earliest possible time would be Oct 2023: https://github.com/nodejs/release#release-schedule import { webcrypto } from 'node:crypto'; -import { Jws, Message, Poller } from '@tbd54566975/dwn-sdk-js'; +import { Jws, Message, Poller, RecordsWrite } from '@tbd54566975/dwn-sdk-js'; import { Web5 } from '../src/web5.js'; // @ts-ignore if (!globalThis.crypto) globalThis.crypto = webcrypto; @@ -3086,6 +3087,145 @@ describe('Record', () => { // bob is the author expect(readResultAlice.record!.author).to.equal(bobDid.uri); }); + + it('updates a record using a different protocolRole than the one used when querying for/reading the record', async () => { + // scenario: Bob has a notes protocol that has friends who can read/query/subscribe to notes, but coAuthors that can update notes. + // When Alice uses her friend role to query for notes, she cannot update them with that same role. Instead she uses her coAuthor role update. + + const protocol = { + ...notesProtocolDefinition, + protocol: 'http://example.com/notes' + TestDataGenerator.randomString(15) + }; + + // Bob configures the notes protocol for himself + const { status: bobProtocolStatus, protocol: bobProtocol } = await dwnBob.protocols.configure({ + message: { + definition: protocol + } + }); + expect(bobProtocolStatus.code).to.equal(202); + const { status: bobProtocolSendStatus } = await bobProtocol.send(bobDid.uri); + expect(bobProtocolSendStatus.code).to.equal(202); + + // Alice must also configure the protocol to make updates. + // NOTE: This is not desireable and there is an issue to address this: + // https://github.com/TBD54566975/web5-js/issues/955 + const { status: aliceProtocolStatus, protocol: aliceProtocol } = await dwnAlice.protocols.configure({ + message: { + definition: protocol + } + }); + expect(aliceProtocolStatus.code).to.equal(202); + const { status: aliceProtocolSend } = await aliceProtocol.send(aliceDid.uri); + expect(aliceProtocolSend.code).to.equal(202); + + // Bob creates a few notes ensuring that the data is larger than the max encoded size + // that way the data will be requested with a separate `read` request + const records: Set = new Set(); + for (let i = 0; i < 3; i++) { + const data = TestDataGenerator.randomString(DwnConstant.maxDataSizeAllowedToBeEncoded + 1); + const { status: noteCreateStatus, record: noteRecord } = await dwnBob.records.create({ + data, + message: { + protocol : protocol.protocol, + protocolPath : 'note', + schema : protocol.types.note.schema, + dataFormat : 'text/plain', + } + }); + expect(noteCreateStatus.code).to.equal(202); + const { status: noteSendStatus } = await noteRecord.send(); + expect(noteSendStatus.code).to.equal(202); + records.add(noteRecord.id); + } + + // Bob makes Alice a `friend` to allow her to read and comment on his notes + const { status: friendCreateStatus, record: friendRecord} = await dwnBob.records.create({ + data : 'friend!', + message : { + recipient : aliceDid.uri, + protocol : protocol.protocol, + protocolPath : 'friend', + schema : protocol.types.friend.schema, + dataFormat : 'text/plain' + } + }); + expect(friendCreateStatus.code).to.equal(202); + const { status: bobFriendSendStatus } = await friendRecord.send(bobDid.uri); + expect(bobFriendSendStatus.code).to.equal(202); + + // Bob makes alice a 'coAuthor' of one of his notes + const aliceCoAuthorNoteId = records.keys().next().value; + const { status: coAuthorStatus, record: coAuthorRecord } = await dwnBob.records.create({ + data : aliceDid.uri, + message : { + parentContextId : aliceCoAuthorNoteId, + recipient : aliceDid.uri, + protocol : protocol.protocol, + protocolPath : 'note/coAuthor', + schema : protocol.types.coAuthor.schema, + dataFormat : 'text/plain' + } + }); + expect(coAuthorStatus.code).to.equal(202); + const { status: coAuthorSendStatus } = await coAuthorRecord.send(bobDid.uri); + expect(coAuthorSendStatus.code).to.equal(202); + + // Alice querying for bob's notes using her friend role + const { status: aliceQueryStatus, records: bobNotesAliceQuery } = await dwnAlice.records.query({ + from : bobDid.uri, + message : { + protocolRole : 'friend', + filter : { + protocol : protocol.protocol, + protocolPath : 'note', + } + } + }); + expect(aliceQueryStatus.code).to.equal(200); + expect(bobNotesAliceQuery).to.not.be.undefined; + expect(bobNotesAliceQuery.length).to.equal(records.size); + + // Alice looks for the record she has a co-author rule on + const coAuthorNote = bobNotesAliceQuery.find((record) => record.id === aliceCoAuthorNoteId); + expect(coAuthorNote).to.not.be.undefined; + + // Alice must import the record to be able to update it + // NOTE this should be removed after: https://github.com/TBD54566975/web5-js/issues/955 + const { status: importStatus } = await coAuthorNote.import(); + expect(importStatus.code).to.equal(202); + + // Alice updates the co-author note without providing a new role + const { status: updateStatus } = await coAuthorNote!.update({ data: 'updated note' }); + expect(updateStatus.code).to.equal(202); + + // spy on sendDwnRequest to ensure that the protocolRole is used to read the data of the notes + const sendDwnRequestSpy = sinon.spy(testHarness.agent, 'sendDwnRequest'); + + // confirm that it starts with 0 calls + expect(sendDwnRequestSpy.callCount).to.equal(0); + + // This is accepted locally but will fail when sending the update to the remote DWN + const { status: sendStatus } = await coAuthorNote.send(bobDid.uri); + expect(sendStatus.code).to.equal(401); + expect(sendDwnRequestSpy.callCount).to.equal(2); // the first call is for the initialWrite + let record = (sendDwnRequestSpy.secondCall.args[0] as ProcessDwnRequest).rawMessage; + let sendAuthorizationRole = getRecordProtocolRole(record); + expect(sendAuthorizationRole).to.equal('friend'); + + const { status: updateStatusCoAuthor } = await coAuthorNote!.update({ data: 'updated note', protocolRole: 'note/coAuthor' }); + expect(updateStatusCoAuthor.code).to.equal(202); + + sendDwnRequestSpy.resetHistory(); + + // Now update the record with the correct role + const { status: sendStatusCoAuthor } = await coAuthorNote.send(bobDid.uri); + expect(sendStatusCoAuthor.code).to.equal(202); + expect(sendDwnRequestSpy.callCount).to.equal(1); // the initialWrite was already sent and added to the sent-cache, only the update is sent + record = (sendDwnRequestSpy.firstCall.args[0] as ProcessDwnRequest).rawMessage; + sendAuthorizationRole = getRecordProtocolRole(record); + expect(sendAuthorizationRole).to.equal('note/coAuthor'); + }); }); describe('delete()', () => { @@ -3659,6 +3799,140 @@ describe('Record', () => { await subscription.close(); }); + + it('deletes a record using a different protocolRole than the one used when querying for/reading the record', async () => { + // scenario: Bob has a notes protocol that has friends who can read/query/subscribe to notes, but coAuthors that can update/delete notes. + // When Alice uses her friend role to query for notes, she cannot delete them with that same role. Instead she uses her coAuthor role to delete. + + const protocol = { + ...notesProtocolDefinition, + protocol: 'http://example.com/notes' + TestDataGenerator.randomString(15) + }; + + // Bob configures the notes protocol for himself + const { status: bobProtocolStatus, protocol: bobProtocol } = await dwnBob.protocols.configure({ + message: { + definition: protocol + } + }); + expect(bobProtocolStatus.code).to.equal(202); + const { status: bobProtocolSendStatus } = await bobProtocol.send(bobDid.uri); + expect(bobProtocolSendStatus.code).to.equal(202); + + // Alice must also configure the protocol to make updates. + // NOTE: This is not desireable and there is an issue to address this: + // https://github.com/TBD54566975/web5-js/issues/955 + const { status: aliceProtocolStatus, protocol: aliceProtocol } = await dwnAlice.protocols.configure({ + message: { + definition: protocol + } + }); + expect(aliceProtocolStatus.code).to.equal(202); + const { status: aliceProtocolSend } = await aliceProtocol.send(aliceDid.uri); + expect(aliceProtocolSend.code).to.equal(202); + + // Bob creates a few notes ensuring that the data is larger than the max encoded size + // that way the data will be requested with a separate `read` request + const records: Set = new Set(); + for (let i = 0; i < 3; i++) { + const data = TestDataGenerator.randomString(DwnConstant.maxDataSizeAllowedToBeEncoded + 1); + const { status: noteCreateStatus, record: noteRecord } = await dwnBob.records.create({ + data, + message: { + protocol : protocol.protocol, + protocolPath : 'note', + schema : protocol.types.note.schema, + dataFormat : 'text/plain', + } + }); + expect(noteCreateStatus.code).to.equal(202); + const { status: noteSendStatus } = await noteRecord.send(); + expect(noteSendStatus.code).to.equal(202); + records.add(noteRecord.id); + } + + // Bob makes Alice a `friend` to allow her to read and comment on his notes + const { status: friendCreateStatus, record: friendRecord} = await dwnBob.records.create({ + data : 'friend!', + message : { + recipient : aliceDid.uri, + protocol : protocol.protocol, + protocolPath : 'friend', + schema : protocol.types.friend.schema, + dataFormat : 'text/plain' + } + }); + expect(friendCreateStatus.code).to.equal(202); + const { status: bobFriendSendStatus } = await friendRecord.send(bobDid.uri); + expect(bobFriendSendStatus.code).to.equal(202); + + // Bob makes alice a 'coAuthor' of one of his notes + const aliceCoAuthorNoteId = records.keys().next().value; + const { status: coAuthorStatus, record: coAuthorRecord } = await dwnBob.records.create({ + data : aliceDid.uri, + message : { + parentContextId : aliceCoAuthorNoteId, + recipient : aliceDid.uri, + protocol : protocol.protocol, + protocolPath : 'note/coAuthor', + schema : protocol.types.coAuthor.schema, + dataFormat : 'text/plain' + } + }); + expect(coAuthorStatus.code).to.equal(202); + const { status: coAuthorSendStatus } = await coAuthorRecord.send(bobDid.uri); + expect(coAuthorSendStatus.code).to.equal(202); + + // Alice querying for bob's notes using her friend role + const { status: aliceQueryStatus, records: bobNotesAliceQuery } = await dwnAlice.records.query({ + from : bobDid.uri, + message : { + protocolRole : 'friend', + filter : { + protocol : protocol.protocol, + protocolPath : 'note', + } + } + }); + expect(aliceQueryStatus.code).to.equal(200); + expect(bobNotesAliceQuery).to.not.be.undefined; + expect(bobNotesAliceQuery.length).to.equal(records.size); + + // Alice looks for the record she has a co-author rule on + const coDeleteNote = bobNotesAliceQuery.find((record) => record.id === aliceCoAuthorNoteId); + expect(coDeleteNote).to.not.be.undefined; + + // spy on sendDwnRequest to ensure that the protocolRole is used to read the data of the notes + const sendDwnRequestSpy = sinon.spy(testHarness.agent, 'sendDwnRequest'); + + // confirm that it starts with 0 calls + expect(sendDwnRequestSpy.callCount).to.equal(0); + + const { status: deleteStatus } = await coDeleteNote.delete({ store: false }); + expect(deleteStatus.code).to.equal(202); + + const { status: sendDeleteStatus } = await coDeleteNote.send(bobDid.uri); + expect(sendDeleteStatus.code).to.equal(401); + + expect(sendDwnRequestSpy.callCount).to.equal(2); // the first call is for the initialWrite + let record = (sendDwnRequestSpy.secondCall.args[0] as ProcessDwnRequest).rawMessage; + let sendAuthorizationRole = getRecordProtocolRole(record); + expect(sendAuthorizationRole).to.equal('friend'); + + sendDwnRequestSpy.resetHistory(); + + // Now update the record with the correct role + const { status: updateStatusCoAuthor } = await coDeleteNote.delete({ protocolRole: 'note/coAuthor', store: false }); + expect(updateStatusCoAuthor.code).to.equal(202, `delete: ${updateStatusCoAuthor.detail}`); + + const { status: sendStatusCoAuthor } = await coDeleteNote.send(bobDid.uri); + expect(sendStatusCoAuthor.code).to.equal(202, `delete send: ${sendStatusCoAuthor.detail}`); + + expect(sendDwnRequestSpy.callCount).to.equal(1); // the initialWrite was already sent and added to the sent-cache, only the update is sent + record = (sendDwnRequestSpy.firstCall.args[0] as ProcessDwnRequest).rawMessage; + sendAuthorizationRole = getRecordProtocolRole(record); + expect(sendAuthorizationRole).to.equal('note/coAuthor'); + }); }); describe('store()', () => {