From 9292f9a4f79f397d555e439e657173384ea151fe Mon Sep 17 00:00:00 2001 From: Daniel Buchner Date: Fri, 2 Feb 2024 10:38:34 -0700 Subject: [PATCH] Record.store(), Record.import(), and roles API support (#385) - Add `store()` method to the `api` `Record` class. - Add `import()` method to the `api` `Record` class. These now allow to import and store initial writes as well as the current state write in local and remote DWNs. --- README.md | 4 + packages/agent/src/dwn-manager.ts | 29 +- packages/agent/src/types/agent.ts | 4 +- packages/agent/tests/dwn-manager.spec.ts | 147 +++- packages/api/src/dwn-api.ts | 1 + packages/api/src/record.ts | 188 ++++- packages/api/src/send-cache.ts | 25 + packages/api/tests/dwn-api.spec.ts | 197 ++++- .../fixtures/protocol-definitions/email.json | 22 + .../fixtures/protocol-definitions/photos.json | 75 ++ packages/api/tests/record.spec.ts | 698 +++++++++++++++--- packages/api/tests/send-cache.spec.ts | 70 ++ 12 files changed, 1321 insertions(+), 139 deletions(-) create mode 100644 packages/api/src/send-cache.ts create mode 100644 packages/api/tests/fixtures/protocol-definitions/photos.json create mode 100644 packages/api/tests/send-cache.spec.ts diff --git a/README.md b/README.md index 7dd0dfd37..01c5359ab 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,10 @@ Each `Record` instance has the following instance methods: - **`text`** - _`function`_: returns the data as a string. - **`send`** - _`function`_: sends the record the instance represents to the DWeb Node endpoints of a provided DID. - **`update`** - _`function`_: takes in a new request object matching the expected method signature of a `write` and overwrites the record. This is a convenience method that allows you to easily overwrite records with less verbosity. +- **`store`** - _`function`_: stores the record in the local DWN instance, offering the following options: + - `import`: imports the record as with an owner-signed override (still subject to Protocol rules, when a record is Protocol-based) +- **`import`** - _`function`_: signs a record with an owner override to import the record into the local DWN instance: + - `store` - _`boolean`_: when false is passed, the record will only be signed with an owner override, not stored in the local DWN instance. Defaults to `true`. ### **`web5.dwn.records.query(request)`** diff --git a/packages/agent/src/dwn-manager.ts b/packages/agent/src/dwn-manager.ts index 44a6be195..c10c3053b 100644 --- a/packages/agent/src/dwn-manager.ts +++ b/packages/agent/src/dwn-manager.ts @@ -63,7 +63,7 @@ type DwnMessage = { data?: Blob; } -const dwnMessageCreators = { +const dwnMessageConstructors = { [DwnInterfaceName.Events + DwnMethodName.Get] : EventsGet, [DwnInterfaceName.Messages + DwnMethodName.Get] : MessagesGet, [DwnInterfaceName.Records + DwnMethodName.Read] : RecordsRead, @@ -245,14 +245,14 @@ export class DwnManager { request: ProcessDwnRequest }) { const { request } = options; - + const rawMessage = request.rawMessage as any; let readableStream: Readable | undefined; // TODO: Consider refactoring to move data transformations imposed by fetch() limitations to the HTTP transport-related methods. if (request.messageType === 'RecordsWrite') { const messageOptions = request.messageOptions as RecordsWriteOptions; - if (request.dataStream && !messageOptions.data) { + if (request.dataStream && !messageOptions?.data) { const { dataStream } = request; let isomorphicNodeReadable: Readable; @@ -266,21 +266,28 @@ export class DwnManager { readableStream = webReadableToIsomorphicNodeReadable(forProcessMessage); } - // @ts-ignore - messageOptions.dataCid = await Cid.computeDagPbCidFromStream(isomorphicNodeReadable); - // @ts-ignore - messageOptions.dataSize ??= isomorphicNodeReadable['bytesRead']; + if (!rawMessage) { + // @ts-ignore + messageOptions.dataCid = await Cid.computeDagPbCidFromStream(isomorphicNodeReadable); + // @ts-ignore + messageOptions.dataSize ??= isomorphicNodeReadable['bytesRead']; + } } } const dwnSigner = await this.constructDwnSigner(request.author); - - const messageCreator = dwnMessageCreators[request.messageType]; - const dwnMessage = await messageCreator.create({ + const dwnMessageConstructor = dwnMessageConstructors[request.messageType]; + const dwnMessage = rawMessage ? await dwnMessageConstructor.parse(rawMessage) : await dwnMessageConstructor.create({ ...request.messageOptions, signer: dwnSigner }); + if (dwnMessageConstructor === RecordsWrite){ + if (request.signAsOwner) { + await (dwnMessage as RecordsWrite).signAsOwner(dwnSigner); + } + } + return { message: dwnMessage.message, dataStream: readableStream }; } @@ -411,7 +418,7 @@ export class DwnManager { const dwnSigner = await this.constructDwnSigner(author); - const messageCreator = dwnMessageCreators[messageType]; + const messageCreator = dwnMessageConstructors[messageType]; const dwnMessage = await messageCreator.create({ ...messageOptions, diff --git a/packages/agent/src/types/agent.ts b/packages/agent/src/types/agent.ts index 97691f724..69520063d 100644 --- a/packages/agent/src/types/agent.ts +++ b/packages/agent/src/types/agent.ts @@ -75,8 +75,10 @@ export type DwnRequest = { */ export type ProcessDwnRequest = DwnRequest & { dataStream?: Blob | ReadableStream | Readable; - messageOptions: unknown; + rawMessage?: unknown; + messageOptions?: unknown; store?: boolean; + signAsOwner?: boolean; }; export type SendDwnRequest = DwnRequest & (ProcessDwnRequest | { messageCid: string }) diff --git a/packages/agent/tests/dwn-manager.spec.ts b/packages/agent/tests/dwn-manager.spec.ts index c3d050ff9..6573949be 100644 --- a/packages/agent/tests/dwn-manager.spec.ts +++ b/packages/agent/tests/dwn-manager.spec.ts @@ -95,17 +95,24 @@ describe('DwnManager', () => { }); describe('processRequest()', () => { - let identity: ManagedIdentity; + let alice: ManagedIdentity; + let bob: ManagedIdentity; beforeEach(async () => { await testAgent.clearStorage(); await testAgent.createAgentDid(); // Creates a new Identity to author the DWN messages. - identity = await testAgent.agent.identityManager.create({ + alice = await testAgent.agent.identityManager.create({ name : 'Alice', didMethod : 'key', kms : 'local' }); + + bob = await testAgent.agent.identityManager.create({ + name : 'Bob', + didMethod : 'key', + kms : 'local' + }); }); it('handles EventsGet', async () => { @@ -113,8 +120,8 @@ describe('DwnManager', () => { // Attempt to process the EventsGet. let eventsGetResponse = await testAgent.agent.dwnManager.processRequest({ - author : identity.did, - target : identity.did, + author : alice.did, + target : alice.did, messageType : 'EventsGet', messageOptions : { cursor: testCursor, @@ -140,8 +147,8 @@ describe('DwnManager', () => { // Write a record to use for the MessagesGet test. let { message, reply: { status: writeStatus } } = await testAgent.agent.dwnManager.processRequest({ - author : identity.did, - target : identity.did, + author : alice.did, + target : alice.did, messageType : 'RecordsWrite', messageOptions : { dataFormat : 'text/plain', @@ -157,8 +164,8 @@ describe('DwnManager', () => { // Attempt to process the MessagesGet. let messagesGetResponse = await testAgent.agent.dwnManager.processRequest({ - author : identity.did, - target : identity.did, + author : alice.did, + target : alice.did, messageType : 'MessagesGet', messageOptions : { messageCids: [messageCid] @@ -187,8 +194,8 @@ describe('DwnManager', () => { it('handles ProtocolsConfigure', async () => { let protocolsConfigureResponse = await testAgent.agent.dwnManager.processRequest({ - author : identity.did, - target : identity.did, + author : alice.did, + target : alice.did, messageType : 'ProtocolsConfigure', messageOptions : { definition: emailProtocolDefinition @@ -211,8 +218,8 @@ describe('DwnManager', () => { it('handles ProtocolsQuery', async () => { // Configure a protocol to use for the ProtocolsQuery test. let protocolsConfigureResponse = await testAgent.agent.dwnManager.processRequest({ - author : identity.did, - target : identity.did, + author : alice.did, + target : alice.did, messageType : 'ProtocolsConfigure', messageOptions : { definition: emailProtocolDefinition @@ -222,8 +229,8 @@ describe('DwnManager', () => { // Attempt to query for the protocol that was just configured. let protocolsQueryResponse = await testAgent.agent.dwnManager.processRequest({ - author : identity.did, - target : identity.did, + author : alice.did, + target : alice.did, messageType : 'ProtocolsQuery', messageOptions : { filter: { protocol: emailProtocolDefinition.protocol }, @@ -252,8 +259,8 @@ describe('DwnManager', () => { // Write a record that can be deleted. let { message, reply: { status: writeStatus } } = await testAgent.agent.dwnManager.processRequest({ - author : identity.did, - target : identity.did, + author : alice.did, + target : alice.did, messageType : 'RecordsWrite', messageOptions : { dataFormat : 'text/plain', @@ -266,8 +273,8 @@ describe('DwnManager', () => { // Attempt to process the RecordsRead. const deleteResponse = await testAgent.agent.dwnManager.processRequest({ - author : identity.did, - target : identity.did, + author : alice.did, + target : alice.did, messageType : 'RecordsDelete', messageOptions : { recordId: writeMessage.recordId @@ -294,8 +301,8 @@ describe('DwnManager', () => { // Write a record that can be queried for. let { message, reply: { status: writeStatus } } = await testAgent.agent.dwnManager.processRequest({ - author : identity.did, - target : identity.did, + author : alice.did, + target : alice.did, messageType : 'RecordsWrite', messageOptions : { dataFormat : 'text/plain', @@ -308,8 +315,8 @@ describe('DwnManager', () => { // Attempt to process the RecordsQuery. const queryResponse = await testAgent.agent.dwnManager.processRequest({ - author : identity.did, - target : identity.did, + author : alice.did, + target : alice.did, messageType : 'RecordsQuery', messageOptions : { filter: { @@ -343,8 +350,8 @@ describe('DwnManager', () => { // Write a record that can be read. let { message, reply: { status: writeStatus } } = await testAgent.agent.dwnManager.processRequest({ - author : identity.did, - target : identity.did, + author : alice.did, + target : alice.did, messageType : 'RecordsWrite', messageOptions : { dataFormat : 'text/plain', @@ -357,8 +364,8 @@ describe('DwnManager', () => { // Attempt to process the RecordsRead. const readResponse = await testAgent.agent.dwnManager.processRequest({ - author : identity.did, - target : identity.did, + author : alice.did, + target : alice.did, messageType : 'RecordsRead', messageOptions : { filter: { @@ -391,8 +398,8 @@ describe('DwnManager', () => { // Attempt to process the RecordsWrite let writeResponse = await testAgent.agent.dwnManager.processRequest({ - author : identity.did, - target : identity.did, + author : alice.did, + target : alice.did, messageType : 'RecordsWrite', messageOptions : { dataFormat: 'text/plain' @@ -414,6 +421,90 @@ describe('DwnManager', () => { expect(writeReply).to.have.property('status'); expect(writeReply.status.code).to.equal(202); }); + + it('handles RecordsWrite messages to sign as owner', async () => { + // bob authors a public record to his dwn + const dataStream = new Blob([ Convert.string('Hello, world!').toUint8Array() ]); + + const bobWrite = await testAgent.agent.dwnManager.processRequest({ + author : bob.did, + target : bob.did, + messageType : 'RecordsWrite', + messageOptions : { + published : true, + schema : 'foo/bar', + dataFormat : 'text/plain' + }, + dataStream, + }); + expect(bobWrite.reply.status.code).to.equal(202); + const message = bobWrite.message as RecordsWriteMessage; + + // alice queries bob's DWN for the record + const queryBobResponse = await testAgent.agent.dwnManager.processRequest({ + messageType : 'RecordsQuery', + author : alice.did, + target : bob.did, + messageOptions : { + filter: { + recordId: message.recordId + } + } + }); + let reply = queryBobResponse.reply as RecordsQueryReply; + expect(reply.status.code).to.equal(200); + expect(reply.entries!.length).to.equal(1); + expect(reply.entries![0].recordId).to.equal(message.recordId); + + // alice attempts to process the rawMessage as is without signing it, should fail + let aliceWrite = await testAgent.agent.dwnManager.processRequest({ + messageType : 'RecordsWrite', + author : alice.did, + target : alice.did, + rawMessage : message, + dataStream, + }); + expect(aliceWrite.reply.status.code).to.equal(401); + + // alice queries to make sure the record is not saved on her dwn + let queryAliceResponse = await testAgent.agent.dwnManager.processRequest({ + messageType : 'RecordsQuery', + author : alice.did, + target : alice.did, + messageOptions : { + filter: { + recordId: message.recordId + } + } + }); + expect(queryAliceResponse.reply.status.code).to.equal(200); + expect(queryAliceResponse.reply.entries!.length).to.equal(0); + + // alice attempts to process the rawMessage again this time marking it to be signed as owner + aliceWrite = await testAgent.agent.dwnManager.processRequest({ + messageType : 'RecordsWrite', + author : alice.did, + target : alice.did, + rawMessage : message, + signAsOwner : true, + dataStream, + }); + expect(aliceWrite.reply.status.code).to.equal(202); + + // alice now queries for the record, it should be there + queryAliceResponse = await testAgent.agent.dwnManager.processRequest({ + messageType : 'RecordsQuery', + author : alice.did, + target : alice.did, + messageOptions : { + filter: { + recordId: message.recordId + } + } + }); + expect(queryAliceResponse.reply.status.code).to.equal(200); + expect(queryAliceResponse.reply.entries!.length).to.equal(1); + }); }); describe('sendDwnRequest()', () => { diff --git a/packages/api/src/dwn-api.ts b/packages/api/src/dwn-api.ts index bc387a4dc..b74e63c06 100644 --- a/packages/api/src/dwn-api.ts +++ b/packages/api/src/dwn-api.ts @@ -366,6 +366,7 @@ export class DwnApi { const { entries, status, cursor } = reply; const records = entries.map((entry: RecordsQueryReplyEntry) => { + const recordOptions = { /** * Extract the `author` DID from the record entry since records may be signed by the diff --git a/packages/api/src/record.ts b/packages/api/src/record.ts index e162d0cd4..933aa9db2 100644 --- a/packages/api/src/record.ts +++ b/packages/api/src/record.ts @@ -1,4 +1,4 @@ -import type { Web5Agent } from '@web5/agent'; +import type { ProcessDwnRequest, SendDwnRequest, Web5Agent } from '@web5/agent'; import type { Readable } from '@web5/common'; import type { RecordsWriteMessage, @@ -6,12 +6,12 @@ import type { RecordsWriteDescriptor, } from '@tbd54566975/dwn-sdk-js'; -import { Convert, NodeStream, Stream } from '@web5/common'; +import { Convert, NodeStream, removeUndefinedProperties, Stream } from '@web5/common'; import { DwnInterfaceName, DwnMethodName } from '@tbd54566975/dwn-sdk-js'; import type { ResponseStatus } from './dwn-api.js'; - import { dataToBlob } from './utils.js'; +import { SendCache } from './send-cache.js'; /** * Options that are passed to Record constructor. @@ -23,6 +23,8 @@ export type RecordOptions = RecordsWriteMessage & { connectedDid: string; encodedData?: string | Blob; data?: Readable | ReadableStream; + initialWrite?: RecordsWriteMessage; + protocolRole?: string; remoteOrigin?: string; }; @@ -33,9 +35,10 @@ export type RecordOptions = RecordsWriteMessage & { * @beta */ export type RecordModel = RecordsWriteDescriptor - & Omit + & Omit & { author: string; + protocolRole?: RecordOptions['protocolRole']; recordId?: string; } @@ -51,6 +54,7 @@ export type RecordUpdateOptions = { dateModified?: RecordsWriteDescriptor['messageTimestamp']; datePublished?: RecordsWriteDescriptor['datePublished']; published?: RecordsWriteDescriptor['published']; + protocolRole?: RecordOptions['protocolRole']; } /** @@ -67,6 +71,10 @@ export type RecordUpdateOptions = { * @beta */ export class Record implements RecordModel { + // Cache to minimize the amount of redundant two-phase commits we do in store() and send() + // Retains awareness of the last 100 records stored/sent for up to 100 target DIDs each. + private static _sendCache = SendCache; + // Record instance metadata. private _agent: Web5Agent; private _connectedDid: string; @@ -77,16 +85,23 @@ export class Record implements RecordModel { // Private variables for DWN `RecordsWrite` message properties. private _author: string; private _attestation?: RecordsWriteMessage['attestation']; + private _authorization?: RecordsWriteMessage['authorization']; private _contextId?: string; private _descriptor: RecordsWriteDescriptor; private _encryption?: RecordsWriteMessage['encryption']; + private _initialWrite: RecordOptions['initialWrite']; + private _initialWriteStored: boolean; + private _initialWriteSigned: boolean; private _recordId: string; - + private _protocolRole: RecordOptions['protocolRole']; // Getters for immutable DWN Record properties. /** Record's signatures attestation */ get attestation(): RecordsWriteMessage['attestation'] { return this._attestation; } + /** Record's signatures attestation */ + get authorization(): RecordsWriteMessage['authorization'] { return this._authorization; } + /** DID that signed the record. */ get author(): string { return this._author; } @@ -102,6 +117,9 @@ export class Record implements RecordModel { /** Record's encryption */ get encryption(): RecordsWriteMessage['encryption'] { return this._encryption; } + /** Record's initial write if the record has been updated */ + get initialWrite(): RecordOptions['initialWrite'] { return this._initialWrite; } + /** Record's ID */ get id() { return this._recordId; } @@ -120,6 +138,9 @@ export class Record implements RecordModel { /** Record's protocol path */ get protocolPath() { return this._descriptor.protocolPath; } + /** Role under which the author is writing the record */ + get protocolRole() { return this._protocolRole; } + /** Record's recipient */ get recipient() { return this._descriptor.recipient; } @@ -146,7 +167,25 @@ export class Record implements RecordModel { /** Record's published status (true/false) */ get published() { return this._descriptor.published; } + /** + * Returns a copy of the raw `RecordsWriteMessage` that was used to create the current `Record` instance. + */ + private get rawMessage(): RecordsWriteMessage { + const message = JSON.parse(JSON.stringify({ + contextId : this._contextId, + recordId : this._recordId, + descriptor : this._descriptor, + attestation : this._attestation, + authorization : this._authorization, + encryption : this._encryption, + })); + + removeUndefinedProperties(message); + return message; + } + constructor(agent: Web5Agent, options: RecordOptions) { + this._agent = agent; /** Store the author DID that originally signed the message as a convenience for developers, so @@ -165,10 +204,13 @@ export class Record implements RecordModel { // RecordsWriteMessage properties. this._attestation = options.attestation; + this._authorization = options.authorization; this._contextId = options.contextId; this._descriptor = options.descriptor; this._encryption = options.encryption; + this._initialWrite = options.initialWrite; this._recordId = options.recordId; + this._protocolRole = options.protocolRole; if (options.encodedData) { // If `encodedData` is set, then it is expected that: @@ -295,25 +337,78 @@ export class Record implements RecordModel { return dataObj; } + /** + * Stores the current record state as well as any initial write to the owner's DWN. + * + * @param importRecord - if true, the record will signed by the owner before storing it to the owner's DWN. Defaults to false. + * @returns the status of the store request + * + * @beta + */ + async store(importRecord: boolean = false): Promise { + // if we are importing the record we sign it as the owner + return this.processRecord({ signAsOwner: importRecord, store: true }); + } + + /** + * Signs the current record state as well as any initial write and optionally stores it to the owner's DWN. + * This is useful when importing a record that was signed by someone else int your own DWN. + * + * @param store - if true, the record will be stored to the owner's DWN after signing. Defaults to true. + * @returns the status of the import request + * + * @beta + */ + async import(store: boolean = true): Promise { + return this.processRecord({ store, signAsOwner: true }); + } + /** * Send the current record to a remote DWN by specifying their DID + * If no DID is specified, the target is assumed to be the owner (connectedDID). + * If an initial write is present and the Record class send cache has no awareness of it, the initial write is sent first * (vs waiting for the regular DWN sync) - * @param target - the DID to send the record to + * @param target - the optional DID to send the record to, if none is set it is sent to the connectedDid * @returns the status of the send record request * @throws `Error` if the record has already been deleted. * * @beta */ - async send(target: string): Promise { - const { reply: { status } } = await this._agent.sendDwnRequest({ - messageType : DwnInterfaceName.Records + DwnMethodName.Write, - author : this._connectedDid, - dataStream : await this.data.blob(), - target : target, - messageOptions : this.toJSON(), - }); + async send(target?: string): Promise { + const initialWrite = this._initialWrite; + target??= this._connectedDid; + + // Is there an initial write? Do we know if we've already sent it to this target? + if (initialWrite && !Record._sendCache.check(this._recordId, target)){ + // We do have an initial write, so prepare it for sending to the target. + const rawMessage = { + ...initialWrite + }; + removeUndefinedProperties(rawMessage); + + const initialState: SendDwnRequest = { + messageType : DwnInterfaceName.Records + DwnMethodName.Write, + author : this._connectedDid, + target : target, + rawMessage + }; + await this._agent.sendDwnRequest(initialState); + + // Set the cache to maintain awareness that we don't need to send the initial write next time. + Record._sendCache.set(this._recordId, target); + } - return { status }; + // Prepare the current state for sending to the target + const latestState: SendDwnRequest = { + messageType : DwnInterfaceName.Records + DwnMethodName.Write, + author : this._connectedDid, + dataStream : await this.data.blob(), + target : target + }; + + latestState.rawMessage = { ...this.rawMessage }; + const { reply } = await this._agent.sendDwnRequest(latestState); + return reply; } /** @@ -324,6 +419,7 @@ export class Record implements RecordModel { return { attestation : this.attestation, author : this.author, + authorization : this.authorization, contextId : this.contextId, dataCid : this.dataCid, dataFormat : this.dataFormat, @@ -337,6 +433,7 @@ export class Record implements RecordModel { parentId : this.parentId, protocol : this.protocol, protocolPath : this.protocolPath, + protocolRole : this.protocolRole, published : this.published, recipient : this.recipient, recordId : this.id, @@ -427,10 +524,18 @@ export class Record implements RecordModel { const responseMessage = message as RecordsWriteMessage; if (200 <= status.code && status.code <= 299) { + // copy the original raw message to the initial write before we update the values. + if (!this._initialWrite) { + this._initialWrite = { ...this.rawMessage }; + } + // Only update the local Record instance mutable properties if the record was successfully (over)written. + this._authorization = responseMessage.authorization; + this._protocolRole = messageOptions.protocolRole; mutableDescriptorProperties.forEach(property => { this._descriptor[property] = responseMessage.descriptor[property]; }); + // Cache data. if (options.data !== undefined) { this._encodedData = dataBlob; @@ -440,6 +545,59 @@ export class Record implements RecordModel { 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. + 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))) { + const initialWriteRequest: ProcessDwnRequest = { + messageType : DwnInterfaceName.Records + DwnMethodName.Write, + rawMessage : this.initialWrite, + author : this._connectedDid, + target : this._connectedDid, + signAsOwner, + store, + }; + + // 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 as RecordsWriteMessage; + + // 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) { + this._initialWriteSigned = true; + this.initialWrite.authorization = responseMessage.authorization; + } + } + } + + // Now that we've processed a potential initial write, we can process the current record state. + const requestOptions: ProcessDwnRequest = { + messageType : DwnInterfaceName.Records + DwnMethodName.Write, + rawMessage : this.rawMessage, + author : this._connectedDid, + target : this._connectedDid, + dataStream : await this.data.blob(), + signAsOwner, + store, + }; + + const agentResponse = await this._agent.processDwnRequest(requestOptions); + const { message, reply: { status } } = agentResponse; + const responseMessage = message as RecordsWriteMessage; + + if (200 <= status.code && status.code <= 299) { + // If we are signing as the owner, make sure to update the current record state's authorization, because now it will have the owner's signature on it. + if (signAsOwner) this._authorization = responseMessage.authorization; + } + + return { status }; + } + /** * Fetches the record's data from the specified DWN. * diff --git a/packages/api/src/send-cache.ts b/packages/api/src/send-cache.ts new file mode 100644 index 000000000..a8bc761bd --- /dev/null +++ b/packages/api/src/send-cache.ts @@ -0,0 +1,25 @@ +export class SendCache { + private static cache = new Map>(); + static sendCacheLimit = 100; + + static set(id: string, target: string): void { + let targetCache = SendCache.cache.get(id) || new Set(); + SendCache.cache.delete(id); + SendCache.cache.set(id, targetCache); + if (this.cache.size > SendCache.sendCacheLimit) { + const firstRecord = SendCache.cache.keys().next().value; + SendCache.cache.delete(firstRecord); + } + targetCache.delete(target); + targetCache.add(target); + if (targetCache.size > SendCache.sendCacheLimit) { + const firstTarget = targetCache.keys().next().value; + targetCache.delete(firstTarget); + } + } + + static check(id: string, target: string): boolean { + let targetCache = SendCache.cache.get(id); + return targetCache ? targetCache.has(target) : false; + } +} \ No newline at end of file diff --git a/packages/api/tests/dwn-api.spec.ts b/packages/api/tests/dwn-api.spec.ts index d04705f20..9b0376d35 100644 --- a/packages/api/tests/dwn-api.spec.ts +++ b/packages/api/tests/dwn-api.spec.ts @@ -9,6 +9,7 @@ import { DwnApi } from '../src/dwn-api.js'; import { testDwnUrl } from './utils/test-config.js'; import { TestUserAgent } from './utils/test-user-agent.js'; import emailProtocolDefinition from './fixtures/protocol-definitions/email.json' assert { type: 'json' }; +import photosProtocolDefinition from './fixtures/protocol-definitions/photos.json' assert { type: 'json' }; let testDwnUrls: string[] = [testDwnUrl]; @@ -261,6 +262,201 @@ describe('DwnApi', () => { expect(result.record).to.exist; expect(await result.record?.data.json()).to.deep.equal(dataJson); }); + + it('creates a role record for another user that they can use to create role-based records', async () => { + /** + * WHAT IS BEING TESTED? + * + * We are testing whether role records can be created for outbound participants + * so they can use them to create records corresponding to the roles they are granted. + * + * TEST SETUP STEPS: + * 1. Configure the photos protocol on Bob and Alice's remote and local DWNs. + * 2. Alice creates a role-based 'friend' record for Bob, updates it, then sends it to her remote DWN. + * 3. Bob creates an album record using the role 'friend', adds Alice as a `participant` of the album and sends the records to Alice. + * 4. Alice fetches the album, and the `participant` record to store it on her local DWN. + * 5. Alice adds Bob as an `updater` of the album and sends the record to Bob and her own remote node. This allows bob to edit photos in the album. + * 6. Alice creates a photo using her participant role and sends it to her own DWN and Bob's DWN. + * 7. Bob updates the photo using his updater role and sends it to Alice and his own DWN. + * 8. Alice fetches the photo and stores it on her local DWN. + */ + + // Configure the photos protocol on Alice and Bob's local and remote DWNs. + const { status: bobProtocolStatus, protocol: bobProtocol } = await dwnBob.protocols.configure({ + message: { + definition: photosProtocolDefinition + } + }); + expect(bobProtocolStatus.code).to.equal(202); + const { status: bobRemoteProtocolStatus } = await bobProtocol.send(bobDid.did); + expect(bobRemoteProtocolStatus.code).to.equal(202); + + const { status: aliceProtocolStatus, protocol: aliceProtocol } = await dwnAlice.protocols.configure({ + message: { + definition: photosProtocolDefinition + } + }); + expect(aliceProtocolStatus.code).to.equal(202); + const { status: aliceRemoteProtocolStatus } = await aliceProtocol.send(aliceDid.did); + expect(aliceRemoteProtocolStatus.code).to.equal(202); + + // Alice creates a role-based 'friend' record, updates it, then sends it to her remote DWN. + const { status: friendCreateStatus, record: friendRecord} = await dwnAlice.records.create({ + data : 'test', + message : { + recipient : bobDid.did, + protocol : photosProtocolDefinition.protocol, + protocolPath : 'friend', + schema : photosProtocolDefinition.types.friend.schema, + dataFormat : 'text/plain' + } + }); + expect(friendCreateStatus.code).to.equal(202); + const { status: friendRecordUpdateStatus } = await friendRecord.update({ data: 'update' }); + expect(friendRecordUpdateStatus.code).to.equal(202); + const { status: aliceFriendSendStatus } = await friendRecord.send(aliceDid.did); + expect(aliceFriendSendStatus.code).to.equal(202); + + // Bob creates an album record using the role 'friend' and sends it to Alice + const { status: albumCreateStatus, record: albumRecord} = await dwnBob.records.create({ + data : 'test', + message : { + recipient : aliceDid.did, + protocol : photosProtocolDefinition.protocol, + protocolPath : 'album', + protocolRole : 'friend', + schema : photosProtocolDefinition.types.album.schema, + dataFormat : 'text/plain' + } + }); + expect(albumCreateStatus.code).to.equal(202); + const { status: bobAlbumSendStatus } = await albumRecord.send(bobDid.did); + expect(bobAlbumSendStatus.code).to.equal(202); + const { status: aliceAlbumSendStatus } = await albumRecord.send(aliceDid.did); + expect(aliceAlbumSendStatus.code).to.equal(202); + + // Bob makes Alice a `participant` and sends the record to her and his own remote node. + const { status: participantCreateStatus, record: participantRecord} = await dwnBob.records.create({ + data : 'test', + message : { + contextId : albumRecord.id, + parentId : albumRecord.id, + recipient : aliceDid.did, + protocol : photosProtocolDefinition.protocol, + protocolPath : 'album/participant', + schema : photosProtocolDefinition.types.participant.schema, + dataFormat : 'text/plain' + } + }); + expect(participantCreateStatus.code).to.equal(202); + const { status: bobParticipantSendStatus } = await participantRecord.send(bobDid.did); + expect(bobParticipantSendStatus.code).to.equal(202); + const { status: aliceParticipantSendStatus } = await participantRecord.send(aliceDid.did); + expect(aliceParticipantSendStatus.code).to.equal(202); + + // Alice fetches the album record as well as the participant record that Bob created and stores it on her local node. + const aliceAlbumReadResult = await dwnAlice.records.read({ + from : aliceDid.did, + message : { + filter: { + recordId: albumRecord.id + } + } + }); + expect(aliceAlbumReadResult.status.code).to.equal(200); + expect(aliceAlbumReadResult.record).to.exist; + const { status: aliceAlbumReadStoreStatus } = await aliceAlbumReadResult.record.store(); + expect(aliceAlbumReadStoreStatus.code).to.equal(202); + + const aliceParticipantReadResult = await dwnAlice.records.read({ + from : aliceDid.did, + message : { + filter: { + recordId: participantRecord.id + } + } + }); + expect(aliceParticipantReadResult.status.code).to.equal(200); + expect(aliceParticipantReadResult.record).to.exist; + const { status: aliceParticipantReadStoreStatus } = await aliceParticipantReadResult.record.store(); + expect(aliceParticipantReadStoreStatus.code).to.equal(202); + + // Using the participant role, Alice can make Bob an `updater` and send the record to him and her own remote node. + // Only updater roles can update the photo record after it's been created. + const { status: updaterCreateStatus, record: updaterRecord} = await dwnAlice.records.create({ + data : 'test', + message : { + contextId : albumRecord.id, + parentId : albumRecord.id, + recipient : bobDid.did, + protocol : photosProtocolDefinition.protocol, + protocolPath : 'album/updater', + protocolRole : 'album/participant', + schema : photosProtocolDefinition.types.updater.schema, + dataFormat : 'text/plain' + } + }); + expect(updaterCreateStatus.code).to.equal(202); + const { status: bobUpdaterSendStatus } = await updaterRecord.send(bobDid.did); + expect(bobUpdaterSendStatus.code).to.equal(202); + const { status: aliceUpdaterSendStatus } = await updaterRecord.send(aliceDid.did); + expect(aliceUpdaterSendStatus.code).to.equal(202); + + // Alice creates a photo using her participant role and sends it to her own DWN and Bob's DWN. + const { status: photoCreateStatus, record: photoRecord} = await dwnAlice.records.create({ + data : 'test', + message : { + contextId : albumRecord.id, + parentId : albumRecord.id, + protocol : photosProtocolDefinition.protocol, + protocolPath : 'album/photo', + protocolRole : 'album/participant', + schema : photosProtocolDefinition.types.photo.schema, + dataFormat : 'text/plain' + } + }); + expect(photoCreateStatus.code).to.equal(202); + const { status:alicePhotoSendStatus } = await photoRecord.send(aliceDid.did); + expect(alicePhotoSendStatus.code).to.equal(202); + const { status: bobPhotoSendStatus } = await photoRecord.send(bobDid.did); + expect(bobPhotoSendStatus.code).to.equal(202); + + // Bob updates the photo using his updater role and sends it to Alice and his own DWN. + const { status: photoUpdateStatus, record: photoUpdateRecord} = await dwnBob.records.write({ + data : 'test again', + store : false, + message : { + contextId : albumRecord.id, + parentId : albumRecord.id, + recordId : photoRecord.id, + dateCreated : photoRecord.dateCreated, + protocol : photosProtocolDefinition.protocol, + protocolPath : 'album/photo', + protocolRole : 'album/updater', + schema : photosProtocolDefinition.types.photo.schema, + dataFormat : 'text/plain' + } + }); + expect(photoUpdateStatus.code).to.equal(202); + const { status:alicePhotoUpdateSendStatus } = await photoUpdateRecord.send(aliceDid.did); + expect(alicePhotoUpdateSendStatus.code).to.equal(202); + const { status: bobPhotoUpdateSendStatus } = await photoUpdateRecord.send(bobDid.did); + expect(bobPhotoUpdateSendStatus.code).to.equal(202); + + // Alice fetches the photo and stores it on her local DWN. + const alicePhotoReadResult = await dwnAlice.records.read({ + from : aliceDid.did, + message : { + filter: { + recordId: photoRecord.id + } + } + }); + expect(alicePhotoReadResult.status.code).to.equal(200); + expect(alicePhotoReadResult.record).to.exist; + const { status: alicePhotoReadStoreStatus } = await alicePhotoReadResult.record.store(); + expect(alicePhotoReadStoreStatus.code).to.equal(202); + }); }); describe('agent store: false', () => { @@ -705,7 +901,6 @@ describe('DwnApi', () => { } } }); - // Confirm that the record does not currently exist on Bob's DWN. expect(result.status.code).to.equal(200); expect(result.records).to.exist; diff --git a/packages/api/tests/fixtures/protocol-definitions/email.json b/packages/api/tests/fixtures/protocol-definitions/email.json index 1e23bf5d2..a7b20623b 100644 --- a/packages/api/tests/fixtures/protocol-definitions/email.json +++ b/packages/api/tests/fixtures/protocol-definitions/email.json @@ -2,12 +2,34 @@ "protocol": "http://email-protocol.xyz", "published": false, "types": { + "thread": { + "schema": "http://email-protocol.xyz/schema/thread", + "dataFormats": ["text/plain"] + }, "email": { "schema": "http://email-protocol.xyz/schema/email", "dataFormats": ["text/plain"] } }, "structure": { + "thread": { + "$actions": [ + { + "who": "recipient", + "of": "thread", + "can": "read" + }, + { + "who": "author", + "of": "thread", + "can": "write" + }, + { + "who": "anyone", + "can": "update" + } + ] + }, "email": { "$actions": [ { diff --git a/packages/api/tests/fixtures/protocol-definitions/photos.json b/packages/api/tests/fixtures/protocol-definitions/photos.json new file mode 100644 index 000000000..1bf1db06a --- /dev/null +++ b/packages/api/tests/fixtures/protocol-definitions/photos.json @@ -0,0 +1,75 @@ +{ + "protocol": "http://photo-protocol.xyz", + "published": true, + "types": { + "album": { + "schema": "http://photo-protocol.xyz/schema/album", + "dataFormats": ["text/plain"] + }, + "photo": { + "schema": "http://photo-protocol.xyz/schema/photo", + "dataFormats": ["text/plain"] + }, + "friend": { + "schema": "http://photo-protocol.xyz/schema/friend", + "dataFormats": ["text/plain"] + }, + "participant": { + "schema": "http://photo-protocol.xyz/schema/participant", + "dataFormats": ["text/plain"] + }, + "updater": { + "schema": "http://photo-protocol.xyz/schema/updater", + "dataFormats": ["text/plain"] + } + }, + "structure": { + "friend": { + "$globalRole": true + }, + "album": { + "$actions": [ + { + "role": "friend", + "can": "write" + } + ], + "participant": { + "$contextRole": true, + "$actions": [ + { + "who": "author", + "of": "album", + "can": "write" + } + ] + }, + "updater": { + "$contextRole": true, + "$actions": [ + { + "role": "album/participant", + "can": "write" + } + ] + }, + "photo": { + "$actions": [ + { + "role": "album/participant", + "can": "write" + }, + { + "role": "album/updater", + "can": "update" + }, + { + "who": "author", + "of": "album", + "can": "write" + } + ] + } + } + } +} diff --git a/packages/api/tests/record.spec.ts b/packages/api/tests/record.spec.ts index 1fdfc0a32..304d4446a 100644 --- a/packages/api/tests/record.spec.ts +++ b/packages/api/tests/record.spec.ts @@ -97,6 +97,159 @@ describe('Record', () => { await testAgent.closeStorage(); }); + it('imports a record that another user wrote', async () => { + + // Install the email protocol for Alice's local DWN. + let { protocol: aliceProtocol, status: aliceStatus } = await dwnAlice.protocols.configure({ + message: { + definition: emailProtocolDefinition + } + }); + expect(aliceStatus.code).to.equal(202); + expect(aliceProtocol).to.exist; + + // Install the email protocol for Alice's remote DWN. + const { status: alicePushStatus } = await aliceProtocol!.send(aliceDid.did); + expect(alicePushStatus.code).to.equal(202); + + // Install the email protocol for Bob's local DWN. + const { protocol: bobProtocol, status: bobStatus } = await dwnBob.protocols.configure({ + message: { + definition: emailProtocolDefinition + } + }); + + expect(bobStatus.code).to.equal(202); + expect(bobProtocol).to.exist; + + // Install the email protocol for Bob's remote DWN. + const { status: bobPushStatus } = await bobProtocol!.send(bobDid.did); + expect(bobPushStatus.code).to.equal(202); + + // Alice creates a new large record and stores it on her own dwn + const { status: aliceEmailStatus, record: aliceEmailRecord } = await dwnAlice.records.write({ + data : TestDataGenerator.randomString(DwnConstant.maxDataSizeAllowedToBeEncoded + 1000), + message : { + recipient : bobDid.did, + protocol : emailProtocolDefinition.protocol, + protocolPath : 'thread', + schema : 'http://email-protocol.xyz/schema/thread', + } + }); + expect(aliceEmailStatus.code).to.equal(202); + const { status: sendStatus } = await aliceEmailRecord!.send(aliceDid.did); + expect(sendStatus.code).to.equal(202); + + + // Bob queries for the record on his own DWN (should not find it) + let bobQueryBobDwn = await dwnBob.records.query({ + from : bobDid.did, + message : { + filter: { + protocol : emailProtocolDefinition.protocol, + protocolPath : 'thread', + } + } + }); + expect(bobQueryBobDwn.status.code).to.equal(200); + expect(bobQueryBobDwn.records.length).to.equal(0); // no results + + // Bob queries for the record that was just created on Alice's remote DWN. + let bobQueryAliceDwn = await dwnBob.records.query({ + from : aliceDid.did, + message : { + filter: { + protocol : emailProtocolDefinition.protocol, + protocolPath : 'thread', + } + } + }); + expect(bobQueryAliceDwn.status.code).to.equal(200); + expect(bobQueryAliceDwn.records.length).to.equal(1); + + // bob imports the record + const importRecord = bobQueryAliceDwn.records[0]; + const { status: importRecordStatus } = await importRecord.import(); + expect(importRecordStatus.code).to.equal(202); + + // bob sends the record to his remote dwn + const { status: importSendStatus } = await importRecord!.send(); + expect(importSendStatus.code).to.equal(202); + + // Bob queries for the record on his own DWN (should now return it) + bobQueryBobDwn = await dwnBob.records.query({ + from : bobDid.did, + message : { + filter: { + protocol : emailProtocolDefinition.protocol, + protocolPath : 'thread', + } + } + }); + expect(bobQueryBobDwn.status.code).to.equal(200); + expect(bobQueryBobDwn.records.length).to.equal(1); + expect(bobQueryBobDwn.records[0].id).to.equal(importRecord.id); + + // Alice updates her record + let { status: aliceEmailStatusUpdated } = await aliceEmailRecord.update({ + data: TestDataGenerator.randomString(DwnConstant.maxDataSizeAllowedToBeEncoded + 1000) + }); + expect(aliceEmailStatusUpdated.code).to.equal(202); + + const { status: sentToSelfStatus } = await aliceEmailRecord!.send(); + expect(sentToSelfStatus.code).to.equal(202); + + const { status: sentToBobStatus } = await aliceEmailRecord!.send(bobDid.did); + expect(sentToBobStatus.code).to.equal(202); + + // Alice updates her record and sends it to her own DWN again + const updatedText = TestDataGenerator.randomString(DwnConstant.maxDataSizeAllowedToBeEncoded + 1000); + let { status: aliceEmailStatusUpdatedAgain } = await aliceEmailRecord.update({ + data: updatedText + }); + expect(aliceEmailStatusUpdatedAgain.code).to.equal(202); + const { status: sentToSelfAgainStatus } = await aliceEmailRecord!.send(); + expect(sentToSelfAgainStatus.code).to.equal(202); + + // Bob queries for the updated record on alice's DWN + bobQueryAliceDwn = await dwnBob.records.query({ + from : aliceDid.did, + message : { + filter: { + protocol : emailProtocolDefinition.protocol, + protocolPath : 'thread', + } + } + }); + expect(bobQueryAliceDwn.status.code).to.equal(200); + expect(bobQueryAliceDwn.records.length).to.equal(1); + const updatedRecord = bobQueryAliceDwn.records[0]; + + // stores the record on his own DWN + const { status: updatedRecordStoredStatus } = await updatedRecord.store(); + expect(updatedRecordStoredStatus.code).to.equal(202); + expect(await updatedRecord.data.text()).to.equal(updatedText); + + // sends the record to his own DWN + const { status: updatedRecordToSelfStatus } = await updatedRecord!.send(); + expect(updatedRecordToSelfStatus.code).to.equal(202); + + // Bob queries for the updated record on his own DWN + bobQueryBobDwn = await dwnBob.records.query({ + from : bobDid.did, + message : { + filter: { + protocol : emailProtocolDefinition.protocol, + protocolPath : 'thread', + } + } + }); + expect(bobQueryBobDwn.status.code).to.equal(200); + expect(bobQueryBobDwn.records.length).to.equal(1); + expect(bobQueryBobDwn.records[0].id).to.equal(importRecord.id); + expect(await bobQueryBobDwn.records[0].data.text()).to.equal(updatedText); + }); + it('should retain all defined properties', async () => { // RecordOptions properties const author = aliceDid.did; @@ -156,7 +309,7 @@ describe('Record', () => { }); // Create a parent record to reference in the RecordsWriteMessage used for validation - const parentRecorsWrite = await RecordsWrite.create({ + const parentRecordsWrite = await RecordsWrite.create({ data : new Uint8Array(await dataBlob.arrayBuffer()), dataFormat, protocol, @@ -171,7 +324,7 @@ describe('Record', () => { data : new Uint8Array(await dataBlob.arrayBuffer()), dataFormat, encryptionInput, - parentId : parentRecorsWrite.recordId, + parentId : parentRecordsWrite.recordId, protocol, protocolPath, published, @@ -205,7 +358,7 @@ describe('Record', () => { expect(record.protocolPath).to.equal(protocolPath); expect(record.recipient).to.equal(recipient); expect(record.schema).to.equal(schema); - expect(record.parentId).to.equal(parentRecorsWrite.recordId); + expect(record.parentId).to.equal(parentRecordsWrite.recordId); expect(record.dataCid).to.equal(recordsWrite.message.descriptor.dataCid); expect(record.dataSize).to.equal(recordsWrite.message.descriptor.dataSize); expect(record.dateCreated).to.equal(recordsWrite.message.descriptor.dateCreated); @@ -1108,10 +1261,8 @@ describe('Record', () => { expect(recordData.size).to.equal(dataTextExceedingMaxSize.length); }); - it('fails to return large data payloads of records signed by another entity after remote dwn.records.query()', async () => { + it('returns large data payloads of records signed by another entity after remote dwn.records.query()', async () => { /** - * ! TODO: Fix this once the bug in `dwn-sdk-js` is resolved. - * * WHAT IS BEING TESTED? * * We are testing whether a large (> `DwnConstant.maxDataSizeAllowedToBeEncoded`) record @@ -1185,8 +1336,20 @@ describe('Record', () => { * 4. Validate that Bob is able to write the record to Alice's remote DWN. */ const { status: sendStatusToAlice } = await queryRecordsFrom[0]!.send(aliceDid.did); - expect(sendStatusToAlice.code).to.equal(401); - expect(sendStatusToAlice.detail).to.equal(`Cannot read properties of undefined (reading 'authorization')`); + expect(sendStatusToAlice.code).to.equal(202); + /** + * 5. Alice queries her remote DWN for the record that Bob just wrote. + */ + const { records: queryRecordsTo, status: queryRecordStatusTo } = await dwnAlice.records.query({ + from : aliceDid.did, + message : { filter: { recordId: record!.id }} + }); + expect(queryRecordStatusTo.code).to.equal(200); + /** + * 6. Validate that Alice is able to access the data payload. + */ + const recordData = await queryRecordsTo[0].data.text(); + expect(recordData).to.deep.equal(dataTextExceedingMaxSize); }); }); }); @@ -1344,8 +1507,7 @@ describe('Record', () => { expect(sendResult.status.code).to.equal(202); }); - // TODO: Fix after changes are made to dwn-sdk-js to include the initial write in every query/read response. - it('fails to write updated records to a remote DWN that is missing the initial write', async () => { + it('automatically sends the initial write and update of a record to a remote DWN', async () => { // Alice writes a message to her agent connected DWN. const { status, record } = await dwnAlice.records.write({ data : 'Hello, world!', @@ -1362,11 +1524,7 @@ describe('Record', () => { // Write the updated record to Alice's remote DWN a second time. const sendResult = await record!.send(aliceDid.did); - expect(sendResult.status.code).to.equal(400); - expect(sendResult.status.detail).to.equal('RecordsWriteGetInitialWriteNotFound: initial write is not found'); - - // TODO: Uncomment the following line after changes are made to dwn-sdk-js to include the initial write in every query/read response. - // expect(sendResult.status.code).to.equal(202); + expect(sendResult.status.code).to.equal(202); }); it('writes large records to remote DWNs that were initially queried from a remote DWN', async () => { @@ -1830,7 +1988,7 @@ describe('Record', () => { }); // Create a parent record to reference in the RecordsWriteMessage used for validation - const parentRecorsWrite = await RecordsWrite.create({ + const parentRecordsWrite = await RecordsWrite.create({ data : new Uint8Array(await dataBlob.arrayBuffer()), dataFormat, protocol, @@ -1845,7 +2003,7 @@ describe('Record', () => { data : new Uint8Array(await dataBlob.arrayBuffer()), dataFormat, encryptionInput, - parentId : parentRecorsWrite.recordId, + parentId : parentRecordsWrite.recordId, protocol, protocolPath, published, @@ -1884,7 +2042,7 @@ describe('Record', () => { expect(recordJson.protocolPath).to.equal(protocolPath); expect(recordJson.recipient).to.equal(recipient); expect(recordJson.schema).to.equal(schema); - expect(recordJson.parentId).to.equal(parentRecorsWrite.recordId); + expect(recordJson.parentId).to.equal(parentRecordsWrite.recordId); expect(recordJson.dataCid).to.equal(recordsWrite.message.descriptor.dataCid); expect(recordJson.dataSize).to.equal(recordsWrite.message.descriptor.dataSize); expect(recordJson.dateCreated).to.equal(recordsWrite.message.descriptor.dateCreated); @@ -1931,8 +2089,7 @@ describe('Record', () => { expect(updatedData).to.equal('bye'); }); - // TODO: Fix after changes are made to dwn-sdk-js to include the initial write in every query/read response. - it('fails to update a record locally that only written to a remote DWN', async () => { + it('updates a record locally that only written to a remote DWN', async () => { // Create a record but do not store it on the local DWN. const { status, record } = await dwnAlice.records.write({ store : false, @@ -1946,43 +2103,45 @@ describe('Record', () => { expect(record).to.not.be.undefined; // Store the data CID of the record before it is updated. - // const dataCidBeforeDataUpdate = record!.dataCid; + const dataCidBeforeDataUpdate = record!.dataCid; // Write the record to a remote DWN. const { status: sendStatus } = await record!.send(aliceDid.did); expect(sendStatus.code).to.equal(202); - /** Attempt to update the record, which should write the updated record the local DWN but - * instead fails due to a missing initial write. */ - const updateResult = await record!.update({ data: 'bye' }); + // fails because record has not been stored in the local dwn yet + let updateResult = await record!.update({ data: 'bye' }); expect(updateResult.status.code).to.equal(400); expect(updateResult.status.detail).to.equal('RecordsWriteGetInitialWriteNotFound: initial write is not found'); - // TODO: Uncomment these lines after the issue mentioned above is fixed. - // expect(updateResult.status.code).to.equal(202); + const { status: recordStoreStatus }= await record.store(); + expect(recordStoreStatus.code).to.equal(202); + + // now succeeds with the update + updateResult = await record!.update({ data: 'bye' }); + expect(updateResult.status.code).to.equal(202); // Confirm that the record was written to the local DWN. - // const readResult = await dwnAlice.records.read({ - // message: { - // filter: { - // recordId: record!.id - // } - // } - // }); - // expect(readResult.status.code).to.equal(200); - // expect(readResult.record).to.not.be.undefined; + const readResult = await dwnAlice.records.read({ + message: { + filter: { + recordId: record!.id + } + } + }); + expect(readResult.status.code).to.equal(200); + expect(readResult.record).to.not.be.undefined; // Confirm that the data CID of the record was updated. - // expect(readResult.record.dataCid).to.not.equal(dataCidBeforeDataUpdate); - // expect(readResult.record.dataCid).to.equal(record!.dataCid); + expect(readResult.record.dataCid).to.not.equal(dataCidBeforeDataUpdate); + expect(readResult.record.dataCid).to.equal(record!.dataCid); // Confirm that the data payload of the record was modified. - // const updatedData = await record!.data.text(); - // expect(updatedData).to.equal('bye'); + const updatedData = await record!.data.text(); + expect(updatedData).to.equal('bye'); }); - // TODO: Fix after changes are made to dwn-sdk-js to include the initial write in every query/read response. - it('fails to update a record locally that was initially read from a remote DWN', async () => { + it('allows to update a record locally that was initially read from a remote DWN if store() is issued', async () => { // Create a record but do not store it on the local DWN. const { status, record } = await dwnAlice.records.write({ store : false, @@ -1996,14 +2155,14 @@ describe('Record', () => { expect(record).to.not.be.undefined; // Store the data CID of the record before it is updated. - // const dataCidBeforeDataUpdate = record!.dataCid; + const dataCidBeforeDataUpdate = record!.dataCid; // Write the record to a remote DWN. const { status: sendStatus } = await record!.send(aliceDid.did); expect(sendStatus.code).to.equal(202); // Read the record from the remote DWN. - const readResult = await dwnAlice.records.read({ + let readResult = await dwnAlice.records.read({ from : aliceDid.did, message : { filter: { @@ -2014,36 +2173,37 @@ describe('Record', () => { expect(readResult.status.code).to.equal(200); expect(readResult.record).to.not.be.undefined; - // Attempt to update the record, which should write the updated record the local DWN. - const updateResult = await readResult.record!.update({ data: 'bye' }); + const readRecord = readResult.record; + + // Attempt to update the record without storing, should fail + let updateResult = await readRecord.update({ data: 'bye' }); expect(updateResult.status.code).to.equal(400); - expect(updateResult.status.detail).to.equal('RecordsWriteGetInitialWriteNotFound: initial write is not found'); - // TODO: Uncomment these lines after the issue mentioned above is fixed. - // expect(updateResult.status.code).to.equal(202); + // store the record locally + const { status: storeStatus } = await readRecord.store(); + expect(storeStatus.code).to.equal(202); + + // Attempt to update the record, which should write the updated record the local DWN. + updateResult = await readRecord.update({ data: 'bye' }); + expect(updateResult.status.code).to.equal(202); // Confirm that the record was written to the local DWN. - // const readResult = await dwnAlice.records.read({ - // message: { - // filter: { - // recordId: record!.id - // } - // } - // }); - // expect(readResult.status.code).to.equal(200); - // expect(readResult.record).to.not.be.undefined; + readResult = await dwnAlice.records.read({ + message: { + filter: { + recordId: record!.id + } + } + }); + expect(readResult.status.code).to.equal(200); + expect(readResult.record).to.not.be.undefined; // Confirm that the data CID of the record was updated. - // expect(readResult.record.dataCid).to.not.equal(dataCidBeforeDataUpdate); - // expect(readResult.record.dataCid).to.equal(record!.dataCid); - - // Confirm that the data payload of the record was modified. - // const updatedData = await record!.data.text(); - // expect(updatedData).to.equal('bye'); + expect(readResult.record.dataCid).to.not.equal(dataCidBeforeDataUpdate); + expect(readResult.record.dataCid).to.equal(readRecord.dataCid); }); - // TODO: Fix after changes are made to dwn-sdk-js to include the initial write in every query/read response. - it('fails to update a record locally that was initially queried from a remote DWN', async () => { + it('updates a record locally that was initially queried from a remote DWN', async () => { // Create a record but do not store it on the local DWN. const { status, record } = await dwnAlice.records.write({ store : false, @@ -2057,7 +2217,7 @@ describe('Record', () => { expect(record).to.not.be.undefined; // Store the data CID of the record before it is updated. - // const dataCidBeforeDataUpdate = record!.dataCid; + const dataCidBeforeDataUpdate = record!.dataCid; // Write the record to a remote DWN. const { status: sendStatus } = await record!.send(aliceDid.did); @@ -2076,33 +2236,37 @@ describe('Record', () => { expect(queryResult.records).to.not.be.undefined; expect(queryResult.records.length).to.equal(1); - // Attempt to update the queried record, which should write the updated record the local DWN. + // Attempt to update the queried record, which will fail because we haven't stored the queried record locally yet const [ queriedRecord ] = queryResult.records; - const updateResult = await queriedRecord!.update({ data: 'bye' }); + let updateResult = await queriedRecord!.update({ data: 'bye' }); expect(updateResult.status.code).to.equal(400); expect(updateResult.status.detail).to.equal('RecordsWriteGetInitialWriteNotFound: initial write is not found'); - // TODO: Uncomment these lines after the issue mentioned above is fixed. - // expect(updateResult.status.code).to.equal(202); + // store the queried record + const { status: queriedStoreStatus } = await queriedRecord.store(); + expect(queriedStoreStatus.code).to.equal(202); + + updateResult = await queriedRecord!.update({ data: 'bye' }); + expect(updateResult.status.code).to.equal(202); // Confirm that the record was written to the local DWN. - // const readResult = await dwnAlice.records.read({ - // message: { - // filter: { - // recordId: record!.id - // } - // } - // }); - // expect(readResult.status.code).to.equal(200); - // expect(readResult.record).to.not.be.undefined; + const readResult = await dwnAlice.records.read({ + message: { + filter: { + recordId: record!.id + } + } + }); + expect(readResult.status.code).to.equal(200); + expect(readResult.record).to.not.be.undefined; // Confirm that the data CID of the record was updated. - // expect(readResult.record.dataCid).to.not.equal(dataCidBeforeDataUpdate); - // expect(readResult.record.dataCid).to.equal(record!.dataCid); + expect(readResult.record.dataCid).to.not.equal(dataCidBeforeDataUpdate); + expect(readResult.record.dataCid).to.equal(queriedRecord!.dataCid); // Confirm that the data payload of the record was modified. - // const updatedData = await record!.data.text(); - // expect(updatedData).to.equal('bye'); + const updatedData = await queriedRecord!.data.text(); + expect(updatedData).to.equal('bye'); }); it('returns new dateModified after each update', async () => { @@ -2152,4 +2316,372 @@ describe('Record', () => { ).to.eventually.be.rejectedWith('is an immutable property. Its value cannot be changed.'); }); }); + + describe('store()', () => { + it('should store an external record if it has been imported by the dwn owner', async () => { + // Scenario: Alice creates a record. + // Bob queries for the record from Alice's DWN and then stores it to their own DWN. + + // alice creates a record and sends it to their DWN + const { status, record } = await dwnAlice.records.write({ + data : 'Hello, world!', + message : { + published : true, + schema : 'foo/bar', + dataFormat : 'text/plain' + } + }); + expect(status.code).to.equal(202, status.detail); + let sendResponse = await record.send(); + expect(sendResponse.status.code).to.equal(202, sendResponse.status.detail); + + // bob queries alice's DWN for the record + const aliceQueryResult = await dwnBob.records.query({ + from : aliceDid.did, + message : { + filter: { + recordId: record.id + } + } + }); + expect(aliceQueryResult.status.code).to.equal(200); + expect(aliceQueryResult.records.length).to.equal(1); + const queriedRecord = aliceQueryResult.records[0]; + + // bob queries their own DWN for the record, should not return any results + let bobQueryResult = await dwnBob.records.query({ + message: { + filter: { + recordId: record.id + } + } + }); + expect(bobQueryResult.status.code).to.equal(200); + expect(bobQueryResult.records.length).to.equal(0); + + // attempts to store the record without importing it, should fail + let { status: storeRecordStatus } = await queriedRecord.store(); + expect(storeRecordStatus.code).to.equal(401, storeRecordStatus.detail); + + // attempts to store the record flagging it for import + ({ status: storeRecordStatus } = await queriedRecord.store(true)); + expect(storeRecordStatus.code).to.equal(202, storeRecordStatus.detail); + + // bob queries their own DWN for the record, should return the record + bobQueryResult = await dwnBob.records.query({ + message: { + filter: { + recordId: record.id + } + } + }); + expect(bobQueryResult.status.code).to.equal(200); + expect(bobQueryResult.records.length).to.equal(1); + const storedRecord = bobQueryResult.records[0]; + expect(storedRecord.id).to.equal(record.id); + }); + + it('stores an updated record to the local DWN along with the initial write', async () => { + // Scenario: Alice creates a record and then updates it. + // Bob queries for the record from Alice's DWN and then stores the updated record along with it's initial write. + + // Alice creates a public record then sends it to her remote DWN. + const { status, record } = await dwnAlice.records.write({ + data : 'Hello, world!', + message : { + published : true, + schema : 'foo/bar', + dataFormat : 'text/plain' + } + }); + expect(status.code).to.equal(202, status.detail); + const updatedText = 'updated text'; + const updateResult = await record!.update({ data: updatedText }); + expect(updateResult.status.code).to.equal(202, updateResult.status.detail); + + const sendResponse = await record.send(); + expect(sendResponse.status.code).to.equal(202, sendResponse.status.detail); + + // Bob queries for the record from his own node, should not return any results + let queryResult = await dwnBob.records.query({ + message: { + filter: { + recordId: record.id + } + } + }); + expect(queryResult.status.code).to.equal(200); + expect(queryResult.records.length).to.equal(0); + + // Bob queries for the record from Alice's remote DWN + const queryResultFromAlice = await dwnBob.records.query({ + from : aliceDid.did, + message : { + filter: { + recordId: record.id + } + } + }); + expect(queryResultFromAlice.status.code).to.equal(200); + expect(queryResultFromAlice.records.length).to.equal(1); + const queriedRecord = queryResultFromAlice.records[0]; + expect(await queriedRecord.data.text()).to.equal(updatedText); + + // attempts to store the record without signing it, should fail + let { status: storeRecordStatus } = await queriedRecord.store(); + expect(storeRecordStatus.code).to.equal(401, storeRecordStatus.detail); + + // stores the record in Bob's DWN, the importRecord parameter is set to true so that bob signs the record before storing it + ({ status: storeRecordStatus } = await queriedRecord.store(true)); + expect(storeRecordStatus.code).to.equal(202, storeRecordStatus.detail); + + // The record should now exist on bob's node + queryResult = await dwnBob.records.query({ + message: { + filter: { + recordId: record.id + } + } + }); + expect(queryResult.status.code).to.equal(200); + expect(queryResult.records.length).to.equal(1); + const storedRecord = queryResult.records[0]; + expect(storedRecord.id).to.equal(record!.id); + expect(await storedRecord.data.text()).to.equal(updatedText); + }); + }); + + describe('import()', () => { + 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 + // Bob then .stores() it without specifying import explicitly as it's already been imported. + + // alice creates a record and sends it to her DWN + const { status, record } = await dwnAlice.records.write({ + data : 'Hello, world!', + message : { + published : true, + schema : 'foo/bar', + dataFormat : 'text/plain' + } + }); + expect(status.code).to.equal(202, status.detail); + let sendResponse = await record.send(); + expect(sendResponse.status.code).to.equal(202, sendResponse.status.detail); + + // bob queries alice's DWN for the record + const aliceQueryResult = await dwnBob.records.query({ + from : aliceDid.did, + message : { + filter: { + recordId: record.id + } + } + }); + expect(aliceQueryResult.status.code).to.equal(200); + expect(aliceQueryResult.records.length).to.equal(1); + const queriedRecord = aliceQueryResult.records[0]; + + // imports the record without storing it + let { status: importRecordStatus } = await queriedRecord.import(); + expect(importRecordStatus.code).to.equal(202, importRecordStatus.detail); + + // bob queries their own DWN for the record, should return the record + const bobQueryResult = await dwnBob.records.query({ + message: { + filter: { + recordId: record.id + } + } + }); + expect(bobQueryResult.status.code).to.equal(200); + expect(bobQueryResult.records.length).to.equal(1); + const storedRecord = bobQueryResult.records[0]; + expect(storedRecord.id).to.equal(record.id); + }); + + it('import an external record along with the initial write', async () => { + // Scenario: Alice creates a record and then updates it. + // Bob queries for the record from Alice's DWN and then stores the updated record along with it's initial write. + + // Alice creates a public record then sends it to her remote DWN. + const { status, record } = await dwnAlice.records.write({ + data : 'Hello, world!', + message : { + published : true, + schema : 'foo/bar', + dataFormat : 'text/plain' + } + }); + expect(status.code).to.equal(202, status.detail); + const updatedText = 'updated text'; + const updateResult = await record!.update({ data: updatedText }); + expect(updateResult.status.code).to.equal(202, updateResult.status.detail); + const sendResponse = await record.send(); + expect(sendResponse.status.code).to.equal(202, sendResponse.status.detail); + + // bob queries alice's DWN for the record + const aliceQueryResult = await dwnBob.records.query({ + from : aliceDid.did, + message : { + filter: { + recordId: record.id + } + } + }); + expect(aliceQueryResult.status.code).to.equal(200); + expect(aliceQueryResult.records.length).to.equal(1); + const queriedRecord = aliceQueryResult.records[0]; + + // imports the record without storing it + let { status: importRecordStatus } = await queriedRecord.import(); + expect(importRecordStatus.code).to.equal(202, importRecordStatus.detail); + + // bob queries their own DWN for the record, should return the record + const bobQueryResult = await dwnBob.records.query({ + message: { + filter: { + recordId: record.id + } + } + }); + expect(bobQueryResult.status.code).to.equal(200); + expect(bobQueryResult.records.length).to.equal(1); + const storedRecord = bobQueryResult.records[0]; + expect(storedRecord.id).to.equal(record.id); + }); + + describe('store: false', () => { + 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 + // Bob then .stores() it without specifying import explicitly as it's already been imported. + + // alice creates a record and sends it to her DWN + const { status, record } = await dwnAlice.records.write({ + data : 'Hello, world!', + message : { + published : true, + schema : 'foo/bar', + dataFormat : 'text/plain' + } + }); + expect(status.code).to.equal(202, status.detail); + let sendResponse = await record.send(); + expect(sendResponse.status.code).to.equal(202, sendResponse.status.detail); + + // bob queries alice's DWN for the record + const aliceQueryResult = await dwnBob.records.query({ + from : aliceDid.did, + message : { + filter: { + recordId: record.id + } + } + }); + expect(aliceQueryResult.status.code).to.equal(200); + expect(aliceQueryResult.records.length).to.equal(1); + const queriedRecord = aliceQueryResult.records[0]; + + // imports the record without storing it + let { status: importRecordStatus } = await queriedRecord.import(false); + expect(importRecordStatus.code).to.equal(202, importRecordStatus.detail); + + // queries for the record from bob's DWN, should not return any results + let bobQueryResult = await dwnBob.records.query({ + message: { + filter: { + recordId: record.id + } + } + }); + expect(bobQueryResult.status.code).to.equal(200); + expect(bobQueryResult.records.length).to.equal(0); + + // attempts to store the record without explicitly marking it for import as it's already been imported + ({ status: importRecordStatus } = await queriedRecord.store()); + expect(importRecordStatus.code).to.equal(202, importRecordStatus.detail); + + // bob queries their own DWN for the record, should return the record + bobQueryResult = await dwnBob.records.query({ + message: { + filter: { + recordId: record.id + } + } + }); + expect(bobQueryResult.status.code).to.equal(200); + expect(bobQueryResult.records.length).to.equal(1); + const storedRecord = bobQueryResult.records[0]; + expect(storedRecord.id).to.equal(record.id); + }); + + it('import an external record along with the initial write', async () => { + // Scenario: Alice creates a record and then updates it. + // Bob queries for the record from Alice's DWN and then stores the updated record along with it's initial write. + + // Alice creates a public record then sends it to her remote DWN. + const { status, record } = await dwnAlice.records.write({ + data : 'Hello, world!', + message : { + published : true, + schema : 'foo/bar', + dataFormat : 'text/plain' + } + }); + expect(status.code).to.equal(202, status.detail); + const updatedText = 'updated text'; + const updateResult = await record.update({ data: updatedText }); + expect(updateResult.status.code).to.equal(202, updateResult.status.detail); + const sendResponse = await record.send(); + expect(sendResponse.status.code).to.equal(202, sendResponse.status.detail); + + // bob queries alice's DWN for the record + const aliceQueryResult = await dwnBob.records.query({ + from : aliceDid.did, + message : { + filter: { + recordId: record.id + } + } + }); + expect(aliceQueryResult.status.code).to.equal(200); + expect(aliceQueryResult.records.length).to.equal(1); + const queriedRecord = aliceQueryResult.records[0]; + + // imports the record without storing it + let { status: importRecordStatus } = await queriedRecord.import(false); + expect(importRecordStatus.code).to.equal(202, importRecordStatus.detail); + + // queries for the record from bob's DWN, should not return any results + let bobQueryResult = await dwnBob.records.query({ + message: { + filter: { + recordId: record.id + } + } + }); + expect(bobQueryResult.status.code).to.equal(200); + expect(bobQueryResult.records.length).to.equal(0); + + // attempts to store the record without explicitly marking it for import as it's already been imported + ({ status: importRecordStatus } = await queriedRecord.store()); + expect(importRecordStatus.code).to.equal(202, importRecordStatus.detail); + + // bob queries their own DWN for the record, should return the record + bobQueryResult = await dwnBob.records.query({ + message: { + filter: { + recordId: record.id + } + } + }); + expect(bobQueryResult.status.code).to.equal(200); + expect(bobQueryResult.records.length).to.equal(1); + const storedRecord = bobQueryResult.records[0]; + expect(storedRecord.id).to.equal(record.id); + }); + }); + }); }); \ No newline at end of file diff --git a/packages/api/tests/send-cache.spec.ts b/packages/api/tests/send-cache.spec.ts new file mode 100644 index 000000000..1d733e3a3 --- /dev/null +++ b/packages/api/tests/send-cache.spec.ts @@ -0,0 +1,70 @@ +import chai, { expect } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; + +import { SendCache } from '../src/send-cache.js'; + +chai.use(chaiAsPromised); + +describe('SendCache', () => { + it('sets and checks an item in the cache', async () => { + // checks for 'id' and 'target', returns false because we have not set them yet + expect(SendCache.check('id', 'target')).to.equal(false); + + // set 'id' and 'target, and then check + SendCache.set('id', 'target'); + expect(SendCache.check('id', 'target')).to.equal(true); + + // check for 'id' with a different target + expect(SendCache.check('id', 'target2')).to.equal(false); + }); + + it('purges the first item in the cache when the target cache is full (100 items)', async () => { + const recordId = 'id'; + // set 100 items in the cache to the same id + for (let i = 0; i < 100; i++) { + SendCache.set(recordId, `target-${i}`); + } + + // check that the first item is in the cache + expect(SendCache.check(recordId, 'target-0')).to.equal(true); + + // set another item in the cache + SendCache.set(recordId, 'target-new'); + + // check that the first item is no longer in the cache but the one after it is as well as the new one. + expect(SendCache.check(recordId, 'target-0')).to.equal(false); + expect(SendCache.check(recordId, 'target-1')).to.equal(true); + expect(SendCache.check(recordId, 'target-new')).to.equal(true); + + // add another item + SendCache.set(recordId, 'target-new2'); + expect(SendCache.check(recordId, 'target-1')).to.equal(false); + expect(SendCache.check(recordId, 'target-2')).to.equal(true); + expect(SendCache.check(recordId, 'target-new2')).to.equal(true); + }); + + it('purges the first item in the cache when the record cache is full (100 items)', async () => { + const target = 'target'; + // set 100 items in the cache to the same id + for (let i = 0; i < 100; i++) { + SendCache.set(`record-${i}`, target); + } + + // check that the first item is in the cache + expect(SendCache.check('record-0', target)).to.equal(true); + + // set another item in the cache + SendCache.set('record-new', target); + + // check that the first item is no longer in the cache but the one after it is as well as the new one. + expect(SendCache.check('record-0', target)).to.equal(false); + expect(SendCache.check('record-1', target)).to.equal(true); + expect(SendCache.check('record-new', target)).to.equal(true); + + // add another item + SendCache.set('record-new2', target); + expect(SendCache.check('record-1', target)).to.equal(false); + expect(SendCache.check('record-2', target)).to.equal(true); + expect(SendCache.check('record-new2', target)).to.equal(true); + }); +}); \ No newline at end of file