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/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..0f176564d 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,13 +6,38 @@ 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'; +class SendCache { + private static cache = new Map(); + static sendCacheLimit = 100; + + static set(id: string, target: string) { + 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){ + let targetCache = SendCache.cache.get(id); + return target && targetCache ? targetCache.has(target) : targetCache; + } +} + /** * Options that are passed to Record constructor. * @@ -23,6 +48,8 @@ export type RecordOptions = RecordsWriteMessage & { connectedDid: string; encodedData?: string | Blob; data?: Readable | ReadableStream; + initialWrite?: RecordsWriteMessage; + protocolRole?: string; remoteOrigin?: string; }; @@ -33,9 +60,10 @@ export type RecordOptions = RecordsWriteMessage & { * @beta */ export type RecordModel = RecordsWriteDescriptor - & Omit + & Omit & { author: string; + protocolRole?: RecordOptions['protocolRole']; recordId?: string; } @@ -51,6 +79,7 @@ export type RecordUpdateOptions = { dateModified?: RecordsWriteDescriptor['messageTimestamp']; datePublished?: RecordsWriteDescriptor['datePublished']; published?: RecordsWriteDescriptor['published']; + protocolRole?: RecordOptions['protocolRole']; } /** @@ -67,6 +96,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 +110,22 @@ 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 _initialWriteProcessed: 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 +141,8 @@ export class Record implements RecordModel { /** Record's encryption */ get encryption(): RecordsWriteMessage['encryption'] { return this._encryption; } + get initialWrite(): RecordOptions['initialWrite'] { return this._initialWrite; } + /** Record's ID */ get id() { return this._recordId; } @@ -120,6 +161,9 @@ export class Record implements RecordModel { /** Record's protocol path */ get protocolPath() { return this._descriptor.protocolPath; } + /** Role under which the author is writting the record */ + get protocolRole() { return this._protocolRole; } + /** Record's recipient */ get recipient() { return this._descriptor.recipient; } @@ -146,7 +190,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 +227,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 +360,85 @@ 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 true. + * @returns the status of the store request + * + * @beta + */ + async store(importRecord: boolean = true): 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 + }; + + // if there is already an authz payload, just pass along the record + if (this._authorization) { + latestState.rawMessage = { ...this.rawMessage }; + } else { + // if there is no authz, pass options so the DWN SDK can construct and sign the record + latestState.messageOptions = this.toJSON(); + } + + const { reply } = await this._agent.sendDwnRequest(latestState); + return reply; } /** @@ -324,6 +449,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 +463,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 +554,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 +575,58 @@ 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(options: { store: boolean, signAsOwner: boolean }): Promise { + const { store, signAsOwner } = options; + + // 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 && !this._initialWriteProcessed) { + const initialWriteRequest: ProcessDwnRequest = { + messageType : DwnInterfaceName.Records + DwnMethodName.Write, + rawMessage : this.initialWrite, + author : this._connectedDid, + target : this._connectedDid, + signAsOwner : 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 it to processed so that we don't repeat this process again + if (200 <= status.code && status.code <= 299) { + this._initialWriteProcessed = true; + if (signAsOwner) 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 : !this.initialWrite && signAsOwner, // we only need to sign this record if it is the initial write and is marked for signing + 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/tests/dwn-api.spec.ts b/packages/api/tests/dwn-api.spec.ts index d04705f20..58bfbe631 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,178 @@ 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 email protocol on Bob's local DWN. + */ + + // Configure the email 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, and sends it to her remote + 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 a thread 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); + + // Alice fetches the album record Bob created using his friend role + 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); + + // Bob makes Alice a `participant` + 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); + + 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); + + // Alice makes Bob an `updater` + 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 + 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); + + 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); + }); }); describe('agent store: false', () => { @@ -705,7 +878,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..fbae234dc 100644 --- a/packages/api/tests/record.spec.ts +++ b/packages/api/tests/record.spec.ts @@ -97,6 +97,115 @@ describe('Record', () => { await testAgent.closeStorage(); }); + // FIRST PASS AT IMPORT + + 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 + 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); + + // Alice queries for the record that was just created on her remote DWN. + const { records: queryRecords, status: queryRecordStatus } = await dwnBob.records.query({ + from : aliceDid.did, + message : { + filter: { + protocol : emailProtocolDefinition.protocol, + protocolPath : 'thread', + } + } + }); + expect(queryRecordStatus.code).to.equal(200); + const importRecord = queryRecords[0]; + const { status: importRecordStatus } = await importRecord.import(); + expect(importRecordStatus.code).to.equal(202); + + const { status: importSendStatus } = await importRecord!.send(); + expect(importSendStatus.code).to.equal(202); + + // 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 + + const updatedText = TestDataGenerator.randomString(DwnConstant.maxDataSizeAllowedToBeEncoded + 1000); + let { status: aliceEmailStatusUpdatedAgain } = await aliceEmailRecord.update({ + data: updatedText + }); + expect(aliceEmailStatusUpdatedAgain.code).to.equal(202); + + // Sends it to her own remote DWN again + const { status: sentToSelfAgainStatus } = await aliceEmailRecord!.send(); + expect(sentToSelfAgainStatus.code).to.equal(202); + + const { records: updatedRecords, status: updatedRecordsStatus } = await dwnBob.records.query({ + from : aliceDid.did, + message : { + filter: { + protocol : emailProtocolDefinition.protocol, + protocolPath : 'thread', + } + } + }); + expect(updatedRecordsStatus.code).to.equal(200); + + const updatedRecord = updatedRecords[0]; + const { status: updatedRecordStoredStatus } = await updatedRecord.store(); + expect(updatedRecordStoredStatus.code).to.equal(202); + + expect(await updatedRecord.data.text() === updatedText).to.equal(true); + + const { status: updatedRecordToSelfStatus } = await updatedRecord!.send(); + expect(updatedRecordToSelfStatus.code).to.equal(202); + + }); + it('should retain all defined properties', async () => { // RecordOptions properties const author = aliceDid.did; @@ -1185,8 +1294,7 @@ 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); }); }); }); @@ -1344,8 +1452,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 +1469,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 () => {