diff --git a/README.md b/README.md index 45135460c..bb6791eb6 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ # Decentralized Web Node (DWN) SDK Code Coverage -![Statements](https://img.shields.io/badge/statements-93.62%25-brightgreen.svg?style=flat) ![Branches](https://img.shields.io/badge/branches-93.2%25-brightgreen.svg?style=flat) ![Functions](https://img.shields.io/badge/functions-91.24%25-brightgreen.svg?style=flat) ![Lines](https://img.shields.io/badge/lines-93.62%25-brightgreen.svg?style=flat) +![Statements](https://img.shields.io/badge/statements-93.69%25-brightgreen.svg?style=flat) ![Branches](https://img.shields.io/badge/branches-93.17%25-brightgreen.svg?style=flat) ![Functions](https://img.shields.io/badge/functions-91.36%25-brightgreen.svg?style=flat) ![Lines](https://img.shields.io/badge/lines-93.69%25-brightgreen.svg?style=flat) ## Introduction diff --git a/build/compile-validators.js b/build/compile-validators.js index 2047100df..1971a3a4b 100644 --- a/build/compile-validators.js +++ b/build/compile-validators.js @@ -18,6 +18,7 @@ import mkdirp from 'mkdirp'; import standaloneCode from 'ajv/dist/standalone/index.js'; import Definitions from '../json-schemas/definitions.json' assert { type: 'json' }; +import EventsGet from '../json-schemas/events/events-get.json' assert { type: 'json' }; import GeneralJwk from '../json-schemas/jwk/general-jwk.json' assert { type: 'json' }; import GeneralJws from '../json-schemas/general-jws.json' assert { type: 'json' }; import HooksWrite from '../json-schemas/hooks/hooks-write.json' assert { type: 'json' }; @@ -39,6 +40,7 @@ const schemas = { RecordsDelete, RecordsQuery, RecordsWrite, + EventsGet, Definitions, GeneralJwk, GeneralJws, diff --git a/json-schemas/events/events-get.json b/json-schemas/events/events-get.json new file mode 100644 index 000000000..ccb77dafa --- /dev/null +++ b/json-schemas/events/events-get.json @@ -0,0 +1,40 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://identity.foundation/dwn/json-schemas/events-get.json", + "type": "object", + "additionalProperties": false, + "required": [ + "authorization", + "descriptor" + ], + "properties": { + "authorization": { + "$ref": "https://identity.foundation/dwn/json-schemas/general-jws.json" + }, + "descriptor": { + "type": "object", + "additionalProperties": false, + "required": [ + "interface", + "method" + ], + "properties": { + "interface": { + "enum": [ + "Events" + ], + "type": "string" + }, + "method": { + "enum": [ + "Get" + ], + "type": "string" + }, + "watermark": { + "type": "string" + } + } + } + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 853266202..e540cf6ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tbd54566975/dwn-sdk-js", - "version": "0.0.29", + "version": "0.0.30", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@tbd54566975/dwn-sdk-js", - "version": "0.0.29", + "version": "0.0.30", "license": "Apache-2.0", "dependencies": { "@ipld/dag-cbor": "9.0.0", diff --git a/package.json b/package.json index 177026742..269ef184e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tbd54566975/dwn-sdk-js", - "version": "0.0.29", + "version": "0.0.30", "description": "A reference implementation of https://identity.foundation/decentralized-web-node/spec/", "type": "module", "types": "./dist/esm/src/index.d.ts", @@ -142,4 +142,4 @@ "url": "https://github.com/TBD54566975/dwn-sdk-js/issues" }, "homepage": "https://github.com/TBD54566975/dwn-sdk-js#readme" -} \ No newline at end of file +} diff --git a/src/core/message-reply.ts b/src/core/message-reply.ts index 0c530fc4a..1c682d586 100644 --- a/src/core/message-reply.ts +++ b/src/core/message-reply.ts @@ -20,7 +20,7 @@ export class MessageReply { status: Status; /** - * Resulting message entries returned from the invocation of the corresponding message. + * Resulting message entries or events returned from the invocation of the corresponding message. * e.g. the resulting messages from a RecordsQuery * Mutually exclusive with `data`. */ diff --git a/src/core/message.ts b/src/core/message.ts index f865c6f5d..28850b92d 100644 --- a/src/core/message.ts +++ b/src/core/message.ts @@ -9,6 +9,7 @@ import { lexicographicalCompare } from '../utils/string.js'; import { validateJsonSchema } from '../schema-validator.js'; export enum DwnInterfaceName { + Events = 'Events', Hooks = 'Hooks', Permissions = 'Permissions', Protocols = 'Protocols', @@ -17,6 +18,7 @@ export enum DwnInterfaceName { export enum DwnMethodName { Configure = 'Configure', + Get = 'Get', Grant = 'Grant', Query = 'Query', Read = 'Read', diff --git a/src/dwn.ts b/src/dwn.ts index 00eefac68..a8705c1b5 100644 --- a/src/dwn.ts +++ b/src/dwn.ts @@ -11,6 +11,7 @@ import { AllowAllTenantGate } from './core/tenant-gate.js'; import { DataStoreLevel } from './store/data-store-level.js'; import { DidResolver } from './did/did-resolver.js'; import { EventLogLevel } from './event-log/event-log-level.js'; +import { EventsGetHandler } from './interfaces/events/handlers/events-get.js'; import { MessageReply } from './core/message-reply.js'; import { MessageStoreLevel } from './store/message-store-level.js'; import { PermissionsRequestHandler } from './interfaces/permissions/handlers/permissions-request.js'; @@ -38,6 +39,7 @@ export class Dwn { this.tenantGate = config.tenantGate!; this.methodHandlers = { + [DwnInterfaceName.Events + DwnMethodName.Get] : new EventsGetHandler(this.didResolver, this.eventLog), [DwnInterfaceName.Permissions + DwnMethodName.Request] : new PermissionsRequestHandler(this.didResolver, this.messageStore, this.dataStore), [DwnInterfaceName.Protocols + DwnMethodName.Configure] : new ProtocolsConfigureHandler( this.didResolver, this.messageStore, this.dataStore, this.eventLog), diff --git a/src/index.ts b/src/index.ts index 16de424eb..7a35fe17c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,8 @@ // export everything that we want to be consumable export type { DwnConfig } from './dwn.js'; export type { DwnServiceEndpoint, ServiceEndpoint, DidDocument, DidResolutionResult, DidResolutionMetadata, DidDocumentMetadata, VerificationMethod } from './did/did-resolver.js'; +export type { EventLog, Event } from './event-log/event-log.js'; +export type { EventsGetMessage, EventsGetReply } from './interfaces/events/types.js'; export type { HooksWriteMessage } from './interfaces/hooks/types.js'; export type { ProtocolDefinition, ProtocolRuleSet, ProtocolsConfigureMessage, ProtocolsQueryMessage } from './interfaces/protocols/types.js'; export type { RecordsDeleteMessage, RecordsQueryMessage, RecordsWriteMessage } from './interfaces/records/types.js'; @@ -22,6 +24,8 @@ export { DwnConstant } from './core/dwn-constant.js'; export { DwnError, DwnErrorCode } from './core/dwn-error.js'; export { DwnInterfaceName, DwnMethodName } from './core/message.js'; export { Encoder } from './utils/encoder.js'; +export { EventLogLevel } from './event-log/event-log-level.js'; +export { EventsGet, EventsGetOptions } from './interfaces/events/messages/events-get.js'; export { Encryption, EncryptionAlgorithm } from './utils/encryption.js'; export { EncryptionInput, KeyEncryptionInput, RecordsWrite, RecordsWriteOptions, CreateFromOptions } from './interfaces/records/messages/records-write.js'; export { HooksWrite, HooksWriteOptions } from './interfaces/hooks/messages/hooks-write.js'; diff --git a/src/interfaces/events/handlers/events-get.ts b/src/interfaces/events/handlers/events-get.ts new file mode 100644 index 000000000..ea40d79ee --- /dev/null +++ b/src/interfaces/events/handlers/events-get.ts @@ -0,0 +1,46 @@ +import type { DidResolver } from '../../../index.js'; +import type { EventLog } from '../../../event-log/event-log.js'; +import type { GetEventsOptions } from '../../../event-log/event-log.js'; +import type { MethodHandler } from '../../types.js'; +import type { EventsGetMessage, EventsGetReply } from '../types.js'; + +import { EventsGet } from '../messages/events-get.js'; +import { MessageReply } from '../../../core/message-reply.js'; +import { authenticate, authorize } from '../../../core/auth.js'; + +type HandleArgs = {tenant: string, message: EventsGetMessage}; + +export class EventsGetHandler implements MethodHandler { + constructor(private didResolver: DidResolver, private eventLog: EventLog) {} + + public async handle({ tenant, message }: HandleArgs): Promise { + let eventsGet: EventsGet; + + try { + eventsGet = await EventsGet.parse(message); + } catch (e) { + return MessageReply.fromError(e, 400); + } + + try { + await authenticate(message.authorization, this.didResolver); + await authorize(tenant, eventsGet); + } catch (e) { + return MessageReply.fromError(e, 401); + } + + // if watermark was provided in message, get all events _after_ the watermark. + // Otherwise, get all events. + let options: GetEventsOptions | undefined; + if (message.descriptor.watermark) { + options = { gt: message.descriptor.watermark }; + } + + const events = await this.eventLog.getEvents(tenant, options); + + return { + status: { code: 200, detail: 'OK' }, + events + }; + } +} \ No newline at end of file diff --git a/src/interfaces/events/messages/events-get.ts b/src/interfaces/events/messages/events-get.ts new file mode 100644 index 000000000..ccd92926f --- /dev/null +++ b/src/interfaces/events/messages/events-get.ts @@ -0,0 +1,38 @@ +import type { SignatureInput } from '../../../jose/jws/general/types.js'; +import type { EventsGetDescriptor, EventsGetMessage } from '../types.js'; + +import { validateAuthorizationIntegrity } from '../../../core/auth.js'; +import { DwnInterfaceName, DwnMethodName, Message } from '../../../core/message.js'; + +export type EventsGetOptions = { + watermark?: string; + authorizationSignatureInput: SignatureInput; +}; + +export class EventsGet extends Message { + + public static async parse(message: EventsGetMessage): Promise { + Message.validateJsonSchema(message); + await validateAuthorizationIntegrity(message); + + return new EventsGet(message); + } + + public static async create(options: EventsGetOptions): Promise { + const descriptor: EventsGetDescriptor = { + interface : DwnInterfaceName.Events, + method : DwnMethodName.Get, + }; + + if (options.watermark) { + descriptor.watermark = options.watermark; + } + + const authorization = await Message.signAsAuthorization(descriptor, options.authorizationSignatureInput); + const message = { descriptor, authorization }; + + Message.validateJsonSchema(message); + + return new EventsGet(message); + } +} \ No newline at end of file diff --git a/src/interfaces/events/types.ts b/src/interfaces/events/types.ts new file mode 100644 index 000000000..c3f35a850 --- /dev/null +++ b/src/interfaces/events/types.ts @@ -0,0 +1,18 @@ +import type { BaseMessage } from '../../core/types.js'; +import type { BaseMessageReply } from '../../core/message-reply.js'; +import type { Event } from '../../event-log/event-log.js'; +import type { DwnInterfaceName, DwnMethodName } from '../../core/message.js'; + +export type EventsGetDescriptor = { + interface : DwnInterfaceName.Events; + method: DwnMethodName.Get; + watermark?: string; +}; + +export type EventsGetMessage = BaseMessage & { + descriptor: EventsGetDescriptor; +}; + +export type EventsGetReply = BaseMessageReply & { + events?: Event[]; +}; \ No newline at end of file diff --git a/tests/dwn.spec.ts b/tests/dwn.spec.ts index 73f37fca9..923854b2e 100644 --- a/tests/dwn.spec.ts +++ b/tests/dwn.spec.ts @@ -1,4 +1,4 @@ -import type { TenantGate } from '../src/index.js'; +import type { EventsGetReply, TenantGate } from '../src/index.js'; import chaiAsPromised from 'chai-as-promised'; import sinon from 'sinon'; @@ -86,6 +86,17 @@ describe('DWN', () => { expect(reply.entries).to.be.empty; }); + it('should process an EventsGet message', async () => { + const alice = await DidKeyResolver.generate(); + const { message } = await TestDataGenerator.generateEventsGet({ requester: alice }); + + const reply: EventsGetReply = await dwn.processMessage(alice.did, message); + + expect(reply.status.code).to.equal(200); + expect(reply.events).to.be.empty; + expect(reply['data']).to.not.exist; + }); + it('#191 - regression - should run JSON schema validation', async () => { const invalidMessage = { descriptor: { diff --git a/tests/interfaces/events/handlers/events-get.spec.ts b/tests/interfaces/events/handlers/events-get.spec.ts new file mode 100644 index 000000000..c7d1f9bf8 --- /dev/null +++ b/tests/interfaces/events/handlers/events-get.spec.ts @@ -0,0 +1,144 @@ +import type { EventsGetReply } from '../../../../src/index.js'; + +import { expect } from 'chai'; +import { TestDataGenerator } from '../../../utils/test-data-generator.js'; +import { + DataStoreLevel, + DidKeyResolver, + DidResolver, + Dwn, + EventLogLevel, + MessageStoreLevel, +} from '../../../../src/index.js'; + +import { Message } from '../../../../src/core/message.js'; + +describe('EventsGetHandler.handle()', () => { + let didResolver: DidResolver; + let messageStore: MessageStoreLevel; + let dataStore: DataStoreLevel; + let eventLog: EventLogLevel; + let dwn: Dwn; + + before(async () => { + didResolver = new DidResolver([new DidKeyResolver()]); + + // important to follow this pattern to initialize and clean the message and data store in tests + // so that different suites can reuse the same block store and index location for testing + messageStore = new MessageStoreLevel({ + blockstoreLocation : 'TEST-MESSAGESTORE', + indexLocation : 'TEST-INDEX' + }); + + dataStore = new DataStoreLevel({ + blockstoreLocation: 'TEST-DATASTORE' + }); + + eventLog = new EventLogLevel({ + location: 'TEST-EVENTLOG' + }); + + dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog }); + }); + + beforeEach(async () => { + // clean up before each test rather than after so that a test does not depend on other tests to do the clean up + await messageStore.clear(); + await dataStore.clear(); + await eventLog.clear(); + }); + + after(async () => { + await dwn.close(); + }); + + it('returns a 401 if tenant is not author', async () => { + const alice = await DidKeyResolver.generate(); + const bob = await DidKeyResolver.generate(); + + const { message } = await TestDataGenerator.generateEventsGet({ requester: alice }); + const reply = await dwn.processMessage(bob.did, message); + + expect(reply.status.code).to.equal(401); + expect(reply.entries).to.not.exist; + expect(reply.data).to.not.exist; + }); + + it('returns a 400 if message is invalid', async () => { + const alice = await DidKeyResolver.generate(); + + const { message } = await TestDataGenerator.generateEventsGet({ requester: alice }); + message['descriptor']['troll'] = 'hehe'; + + const reply = await dwn.processMessage(alice.did, message); + + expect(reply.status.code).to.equal(400); + expect(reply.entries).to.not.exist; + expect(reply.data).to.not.exist; + }); + + it('returns all events for a tenant if watermark is not provided', async () => { + const alice = await DidKeyResolver.generate(); + const expectedCids: string[] = []; + + for (let i = 0; i < 5; i += 1) { + const { message, dataStream } = await TestDataGenerator.generateRecordsWrite({ requester: alice }); + const reply = await dwn.processMessage(alice.did, message, dataStream); + + expect(reply.status.code).to.equal(202); + const messageCid = await Message.getCid(message); + expectedCids.push(messageCid); + + } + + const { message } = await TestDataGenerator.generateEventsGet({ requester: alice }); + const reply: EventsGetReply = await dwn.processMessage(alice.did, message); + + expect(reply.status.code).to.equal(200); + expect(reply['data']).to.not.exist; + expect(reply.events?.length).to.equal(expectedCids.length); + + for (let i = 0; i < reply.events!.length; i += 1) { + expect(reply.events![i].messageCid).to.equal(expectedCids[i]); + } + }); + + it('returns all events after watermark if watermark is provided', async () => { + const alice = await DidKeyResolver.generate(); + + for (let i = 0; i < 5; i += 1) { + const { message, dataStream } = await TestDataGenerator.generateRecordsWrite({ requester: alice }); + const reply = await dwn.processMessage(alice.did, message, dataStream); + + expect(reply.status.code).to.equal(202); + } + + const { message } = await TestDataGenerator.generateEventsGet({ requester: alice }); + let reply: EventsGetReply = await dwn.processMessage(alice.did, message); + + expect(reply.status.code).to.equal(200); + + const watermark = reply.events![reply.events!.length - 1].watermark; + const expectedCids: string[] = []; + + for (let i = 0; i < 3; i += 1) { + const { message, dataStream } = await TestDataGenerator.generateRecordsWrite({ requester: alice }); + const reply = await dwn.processMessage(alice.did, message, dataStream); + + expect(reply.status.code).to.equal(202); + const messageCid = await Message.getCid(message); + expectedCids.push(messageCid); + } + + const { message: m } = await TestDataGenerator.generateEventsGet({ requester: alice, watermark }); + reply = await dwn.processMessage(alice.did, m); + + expect(reply.status.code).to.equal(200); + expect(reply['data']).to.not.exist; + expect(reply.events!.length).to.equal(expectedCids.length); + + for (let i = 0; i < reply.events!.length; i += 1) { + expect(reply.events![i].messageCid).to.equal(expectedCids[i]); + } + }); +}); \ No newline at end of file diff --git a/tests/interfaces/events/messages/events-get.spec.ts b/tests/interfaces/events/messages/events-get.spec.ts new file mode 100644 index 000000000..47b718371 --- /dev/null +++ b/tests/interfaces/events/messages/events-get.spec.ts @@ -0,0 +1,71 @@ + +import { EventsGet } from '../../../../src/interfaces/events/messages/events-get.js'; +import { expect } from 'chai'; +import { Jws } from '../../../../src/index.js'; +import { Message } from '../../../../src/core/message.js'; +import { TestDataGenerator } from '../../../utils/test-data-generator.js'; + +describe('EventsGet Message', () => { + describe('create', () => { + it('creates an EventsGet message', async () => { + const alice = await TestDataGenerator.generatePersona(); + const eventsGet = await EventsGet.create({ + watermark : 'yolo', + authorizationSignatureInput : await Jws.createSignatureInput(alice) + }); + + const { message } = eventsGet; + expect(message.descriptor).to.exist; + expect(message.descriptor.watermark).to.equal('yolo'); + expect(message.authorization).to.exist; + }); + + it('doesnt require a watermark', async () => { + const alice = await TestDataGenerator.generatePersona(); + const eventsGet = await EventsGet.create({ + authorizationSignatureInput: await Jws.createSignatureInput(alice) + }); + + const message = eventsGet.message; + expect(message.descriptor).to.exist; + expect(message.descriptor.watermark).to.not.exist; + expect(message.authorization).to.exist; + }); + }); + + describe('parse', () => { + it('parses a message into an EventsGet instance', async () => { + const alice = await TestDataGenerator.generatePersona(); + const eventsGet = await EventsGet.create({ + watermark : 'yolo', + authorizationSignatureInput : await Jws.createSignatureInput(alice) + }); + + const parsed = await EventsGet.parse(eventsGet.message); + expect(parsed).to.be.instanceof(EventsGet); + + const expectedMessageCid = await Message.getCid(eventsGet.message); + const messageCid = await Message.getCid(parsed.message); + + expect(messageCid).to.equal(expectedMessageCid); + }); + + it('throws an exception if message is not a valid EventsGet message', async () => { + const alice = await TestDataGenerator.generatePersona(); + const eventsGet = await EventsGet.create({ + watermark : 'yolo', + authorizationSignatureInput : await Jws.createSignatureInput(alice) + }); + + const { message } = eventsGet; + message['hehe'] = 'troll'; + + try { + await EventsGet.parse(message as any); + expect.fail(); + } catch (e: any) { + expect(e.message).to.include('additional properties'); + } + }); + }); +}); \ No newline at end of file diff --git a/tests/utils/test-data-generator.ts b/tests/utils/test-data-generator.ts index b81bfe2c3..01f844538 100644 --- a/tests/utils/test-data-generator.ts +++ b/tests/utils/test-data-generator.ts @@ -5,6 +5,8 @@ import type { RecordsQueryFilter } from '../../src/interfaces/records/types.js'; import type { CreateFromOptions, EncryptionInput } from '../../src/interfaces/records/messages/records-write.js'; import type { DateSort, + EventsGetMessage, + EventsGetOptions, HooksWriteMessage, HooksWriteOptions, ProtocolDefinition, @@ -32,6 +34,7 @@ import { sha256 } from 'multiformats/hashes/sha2'; import { DidKeyResolver, + EventsGet, HooksWrite, Jws, ProtocolsConfigure, @@ -161,6 +164,17 @@ export type GenerateHooksWriteOutput = { message: HooksWriteMessage; }; +export type GenerateEventsGetInput = { + requester?: Persona; + watermark?: string; +}; + +export type GenerateEventsGetOutput = { + requester: Persona; + eventsGet: EventsGet; + message: EventsGetMessage; +}; + /** * Utility class for generating data for testing. */ @@ -438,6 +452,24 @@ export class TestDataGenerator { return { message: permissionRequest.message }; } + public static async generateEventsGet(input?: GenerateEventsGetInput): Promise { + const requester = input?.requester ?? await TestDataGenerator.generatePersona(); + const authorizationSignatureInput = Jws.createSignatureInput(requester); + + const options: EventsGetOptions = { authorizationSignatureInput }; + if (input?.watermark) { + options.watermark = input.watermark; + } + + const eventsGet = await EventsGet.create(options); + + return { + requester, + eventsGet, + message: eventsGet.message + }; + } + /** * Generates a random alpha-numeric string. */